mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
Merge remote-tracking branch 'origin/release/v3.8.50' into fix/audio-bridge-multipart-runtime
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White
|
||||
1
changelog.d/fixes/10230-deepseek-native-max-effort.md
Normal file
1
changelog.d/fixes/10230-deepseek-native-max-effort.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `<model>-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Claude Code / CC-protocol-compatible clients sending `cache_control` with no `ttl` on the native Claude OAuth path (`claude`/`cc`) now default to the 1h extended cache TTL instead of silently falling back to Anthropic's 5-minute default, even though the 1h beta is always negotiated on this path — thanks @jeff-alves
|
||||
@@ -27,7 +27,20 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the
|
||||
// explicit supportsXHighEffort:false makes the sanitizer map xhigh → max.
|
||||
{
|
||||
id: "glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: false,
|
||||
},
|
||||
{
|
||||
id: "glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: false,
|
||||
},
|
||||
// #3110: MiniMax M3 via Ollama
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
|
||||
@@ -18,7 +18,16 @@ export const xai_oauthProvider: RegistryEntry = {
|
||||
tokenUrl: "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
models: [
|
||||
{ id: "grok-4.5", name: "Grok 4.5", contextLength: 500000 },
|
||||
// SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so
|
||||
// chatCore translates OpenAI Chat Completions → Responses (messages→input,
|
||||
// max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit
|
||||
// /v1/responses with a chat-shaped body → 422 missing `input` (#10165).
|
||||
{
|
||||
id: "grok-4.5",
|
||||
name: "Grok 4.5",
|
||||
contextLength: 500000,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
...(xaiProvider.models || []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "../services/tokenRefresh.ts";
|
||||
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
||||
import { signRequestBody } from "../services/claudeCodeCCH.ts";
|
||||
import { normalizeCacheControlTtl } from "../services/claudeCodeConstraints.ts";
|
||||
import {
|
||||
appendAnthropicBetaHeader,
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
|
||||
@@ -1118,6 +1119,7 @@ export class BaseExecutor {
|
||||
}
|
||||
sysBlocks.unshift({ type: "text", text: billingLine }, { type: "text", text: SENTINEL });
|
||||
tb.system = sysBlocks;
|
||||
normalizeCacheControlTtl(tb);
|
||||
|
||||
// Run the configurable system-transforms pipeline for the native
|
||||
// `claude` provider (issue #2260 / comment 4459544580). The default
|
||||
|
||||
@@ -154,7 +154,8 @@ export function supportsMaxEffortForProvider(provider: string, model: string): b
|
||||
// upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path
|
||||
// (pi#4055) is the documented inverse and expects xhigh, not max.
|
||||
// Ollama Cloud also accepts literal max (for example GLM 5.2 supports
|
||||
// low|medium|high|max|none) and rejects xhigh.
|
||||
// low|medium|high|max|none) and rejects xhigh; xhigh is mapped to max by the
|
||||
// provider guard in sanitizeReasoningEffortForProvider.
|
||||
const isOpencodeGoDeepSeek =
|
||||
(provider === "opencode-go" || provider === "opencode-zen") &&
|
||||
resolvedModelId.toLowerCase().includes("deepseek");
|
||||
@@ -284,6 +285,18 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
|
||||
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh. Map
|
||||
// xhigh → max (its literal top tier) before the generic xhigh handling so
|
||||
// passthrough (unregistered) models are covered too — the registry opt-out
|
||||
// only covers known models.
|
||||
if (provider === "ollama-cloud" && effortStr === "xhigh") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort xhigh → max`
|
||||
);
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
|
||||
// Native DeepSeek (api.deepseek.com) — V4 thinking mode uses the native
|
||||
// {low, high, max} vocabulary on Flash and {high, max} on Pro. OmniRoute's
|
||||
// internal top tier xhigh maps to DeepSeek's literal max. Pro's unsupported
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
import { isResponsesEndpointPath } from "../utils/responsesEndpoint.ts";
|
||||
import { chatRequestToXaiResponses } from "@/lib/providers/xai/translators/openai-chat.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -124,12 +125,38 @@ export class XaiExecutor extends BaseExecutor {
|
||||
const record = asRecord(cleaned);
|
||||
if (!record) return cleaned;
|
||||
|
||||
const out: JsonRecord = { ...record };
|
||||
let out: JsonRecord = { ...record };
|
||||
const nativeXaiPassthrough = record._nativeXaiResponsesPassthrough === true;
|
||||
delete out._nativeXaiResponsesPassthrough;
|
||||
delete out._nativeCodexPassthrough;
|
||||
|
||||
if (nativeXaiPassthrough || getModelTargetFormat(this.provider, model) === "openai-responses") {
|
||||
const useResponses =
|
||||
nativeXaiPassthrough ||
|
||||
getModelTargetFormat(this.provider, model) === "openai-responses" ||
|
||||
isResponsesEndpointPath(credentials?.requestEndpointPath);
|
||||
|
||||
// #10165: chat/completions clients send messages + max_tokens; xAI /v1/responses
|
||||
// requires input + max_output_tokens. Convert at the executor edge so a missed
|
||||
// chatCore translation cannot ship a chat-shaped body to Responses.
|
||||
if (useResponses) {
|
||||
if (Array.isArray(out.messages) && out.input == null) {
|
||||
out = chatRequestToXaiResponses(out as never) as unknown as JsonRecord;
|
||||
} else {
|
||||
if (out.max_completion_tokens != null && out.max_output_tokens == null) {
|
||||
out.max_output_tokens = out.max_completion_tokens;
|
||||
delete out.max_completion_tokens;
|
||||
}
|
||||
if (out.max_tokens != null && out.max_output_tokens == null) {
|
||||
out.max_output_tokens = out.max_tokens;
|
||||
delete out.max_tokens;
|
||||
}
|
||||
if (out.response_format != null && out.text == null) {
|
||||
out.text = { format: out.response_format };
|
||||
delete out.response_format;
|
||||
}
|
||||
}
|
||||
// Keep model id from the routed request when the translator left it empty.
|
||||
if (out.model == null && model) out.model = model;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 2. Disable thinking when tool_choice forces a specific tool
|
||||
* 3. Enforce max 4 cache_control breakpoints
|
||||
* 4. Normalize cache_control TTL ordering
|
||||
* 5. Default missing cache_control.ttl to "1h" on the native Claude OAuth path
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -156,3 +157,46 @@ export function ensureCacheControlOnLastUserMessage(body: Record<string, unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Claude Code (and CC-protocol-compatible clients) commonly send
|
||||
* `cache_control: { type: "ephemeral" }` with no `ttl`. On the native Claude
|
||||
* OAuth path the outbound anthropic-beta set always includes
|
||||
* extended-cache-ttl-2025-04-11 (see ANTHROPIC_BETA_BASE /
|
||||
* ANTHROPIC_BETA_CLAUDE_OAUTH in anthropicHeaders.ts), so requesting the 1h
|
||||
* TTL is always valid here — but Anthropic only honors it when `ttl` is
|
||||
* explicitly set; an absent `ttl` silently falls back to the platform
|
||||
* default of 5 minutes even though the 1h beta was negotiated. Any pause
|
||||
* longer than 5 minutes between turns then forces a full prefix rewrite
|
||||
* instead of a cache hit. Default the ttl to "1h" wherever it's missing;
|
||||
* never touch a cache_control that already specifies one (explicit client
|
||||
* choice is preserved).
|
||||
*/
|
||||
export function normalizeCacheControlTtl(body: Record<string, unknown>): void {
|
||||
const defaultMissingTtl = (block: Record<string, unknown> | null | undefined) => {
|
||||
const cc = block?.cache_control as Record<string, unknown> | undefined;
|
||||
if (cc && cc.type === "ephemeral" && cc.ttl === undefined) {
|
||||
cc.ttl = "1h";
|
||||
}
|
||||
};
|
||||
|
||||
const system = body.system as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(system)) {
|
||||
for (const block of system) defaultMissingTtl(block);
|
||||
}
|
||||
|
||||
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(tools)) {
|
||||
for (const tool of tools) defaultMissingTtl(tool);
|
||||
}
|
||||
|
||||
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(messages)) {
|
||||
for (const message of messages) {
|
||||
const content = message.content as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) defaultMissingTtl(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
|
||||
// do this by default). Gemini's function_declarations schema doesn't recognize
|
||||
// it and 400s the same way ("Unknown name \"strict\" ... Cannot find field").
|
||||
"strict",
|
||||
// Codex's multi-agent collaboration tools (spawn_agent / send_message /
|
||||
// followup_task) mark their `message` parameter schema with a non-standard
|
||||
// `encrypted: true` annotation (JsonSchema::with_encrypted). Gemini's
|
||||
// function_declarations schema doesn't recognize it and 400s the same way
|
||||
// ("Unknown name \"encrypted\" ... Cannot find field").
|
||||
"encrypted",
|
||||
// NOTE: `pattern` is intentionally NOT in this set. Antigravity (Gemini-derived
|
||||
// surface) accepts `pattern` on string constraints, and glob/grep/file-search
|
||||
// tools depend on it to express their argument regex. Removing it produced
|
||||
@@ -702,10 +708,7 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown {
|
||||
}
|
||||
|
||||
const record = obj as JsonRecord;
|
||||
if (
|
||||
!record.type &&
|
||||
(record.properties !== undefined || record.required !== undefined)
|
||||
) {
|
||||
if (!record.type && (record.properties !== undefined || record.required !== undefined)) {
|
||||
record.type = "object";
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,31 @@ function normalizeOpenAIResponsesRequest(body) {
|
||||
|
||||
const normalized = promoteStrayReasoningEffort({ ...body });
|
||||
|
||||
// #10165 safety net: if a chat-shaped body reached Responses normalization
|
||||
// without input, promote messages → input and map token/format fields.
|
||||
if (normalized.input == null && Array.isArray(normalized.messages)) {
|
||||
normalized.input = normalized.messages;
|
||||
delete normalized.messages;
|
||||
}
|
||||
if (normalized.max_output_tokens == null) {
|
||||
if (normalized.max_completion_tokens != null) {
|
||||
normalized.max_output_tokens = normalized.max_completion_tokens;
|
||||
delete normalized.max_completion_tokens;
|
||||
} else if (normalized.max_tokens != null) {
|
||||
normalized.max_output_tokens = normalized.max_tokens;
|
||||
delete normalized.max_tokens;
|
||||
}
|
||||
} else {
|
||||
delete normalized.max_tokens;
|
||||
delete normalized.max_completion_tokens;
|
||||
}
|
||||
if (normalized.response_format != null && normalized.text == null) {
|
||||
normalized.text = { format: normalized.response_format };
|
||||
delete normalized.response_format;
|
||||
} else if (normalized.response_format != null) {
|
||||
delete normalized.response_format;
|
||||
}
|
||||
|
||||
if (typeof normalized.input === "string") {
|
||||
normalized.input = [
|
||||
{
|
||||
|
||||
@@ -487,8 +487,15 @@ export async function DELETE(request) {
|
||||
);
|
||||
}
|
||||
|
||||
// A custom row and a synced row can share one id (the operator manually added
|
||||
// a model the provider also reports). This route is addressed by id alone, so
|
||||
// it cannot tell which of the two the operator clicked. Remove the custom row
|
||||
// first and treat its presence as the intent: deleting the manually-added
|
||||
// entry must leave the provider-synced sibling alone.
|
||||
const removedCustom = await removeCustomModel(provider, modelId);
|
||||
const removedSynced = await removeSyncedAvailableModel(provider, modelId);
|
||||
const removedSynced = removedCustom
|
||||
? false
|
||||
: await removeSyncedAvailableModel(provider, modelId);
|
||||
if (removedSynced) {
|
||||
// #3199 + #3782: mark the deleted synced model with the DISTINCT `isDeleted`
|
||||
// marker so a later auto-fetch re-import does not re-add it. We also keep
|
||||
@@ -496,6 +503,12 @@ export async function DELETE(request) {
|
||||
// filter keys on `isDeleted` (not `isHidden`), which is what lets an
|
||||
// eye/visibility-hidden model (`isHidden` only) survive a re-sync while a
|
||||
// deleted one stays dropped.
|
||||
//
|
||||
// Only reached when NO custom row owned the id. Tombstoning on a custom-row
|
||||
// delete would permanently suppress the synced sibling: every later sync
|
||||
// reports `added: N` while `replaceSyncedAvailableModelsForConnection`
|
||||
// filters the id straight back out, so the model never returns to
|
||||
// `/v1/models` and the provider looks empty despite routing fine.
|
||||
mergeModelCompatOverride(provider, modelId, { isDeleted: true, isHidden: true });
|
||||
}
|
||||
const removed = removedCustom || removedSynced;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import {
|
||||
CANONICAL_EFFORT_VALUES,
|
||||
extendCodexGpt56EffortValues,
|
||||
extendDeepSeekEffortValues,
|
||||
} from "@/shared/reasoning/effortStandardization";
|
||||
|
||||
export interface CustomModelEntry {
|
||||
@@ -93,8 +94,7 @@ export function getThinkingCapabilityFields(
|
||||
): Record<string, boolean | string[]> {
|
||||
const supportsThinking = resolvedThinking;
|
||||
if (typeof supportsThinking !== "boolean") return {};
|
||||
const hasDeclaredTiers =
|
||||
supportedThinkingEfforts && supportedThinkingEfforts.length > 0;
|
||||
const hasDeclaredTiers = supportedThinkingEfforts && supportedThinkingEfforts.length > 0;
|
||||
return {
|
||||
thinking: supportsThinking,
|
||||
supportsThinking,
|
||||
@@ -102,7 +102,11 @@ export function getThinkingCapabilityFields(
|
||||
? {
|
||||
effort_tiers: hasDeclaredTiers
|
||||
? [...supportedThinkingEfforts!]
|
||||
: extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES),
|
||||
: extendDeepSeekEffortValues(
|
||||
providerId,
|
||||
modelId,
|
||||
extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES)
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -556,6 +556,17 @@ function buildModelOptions(
|
||||
return modelMap;
|
||||
}
|
||||
|
||||
function rewriteQualifiedModelPrefix(
|
||||
modelMap: Map<string, ComboBuilderModelOption>,
|
||||
providerId: string,
|
||||
routingPrefix: string
|
||||
): void {
|
||||
if (routingPrefix === providerId) return;
|
||||
for (const option of modelMap.values()) {
|
||||
option.qualifiedModel = `${routingPrefix}/${option.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #6957: some providers' own catalogs assign the identical display `name` to
|
||||
* several distinct model ids (e.g. Mistral's "codestral-latest" alias renders
|
||||
@@ -684,6 +695,12 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
customModels
|
||||
);
|
||||
|
||||
// #2901 follow-up: a configured OpenCode connection shadows the no-auth
|
||||
// entry below, so it must receive the same `oc/` routing prefix. The raw
|
||||
// `opencode/` prefix is reserved by model parsing for the api-key tier.
|
||||
const routingPrefix = providerId === "opencode" ? providerVisual.alias : providerId;
|
||||
rewriteQualifiedModelPrefix(modelMap, providerId, routingPrefix);
|
||||
|
||||
const normalizedConnections =
|
||||
expandConnectionOptions(providerConnections).sort(compareConnections);
|
||||
|
||||
@@ -749,11 +766,7 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
// (manual ALIAS_TO_PROVIDER_ID override), while "oc/<model>" resolves to the
|
||||
// no-auth "opencode" provider. Rewrite qualifiedModel to the alias prefix.
|
||||
const routingPrefix = noAuthProvider.alias || providerId;
|
||||
if (routingPrefix !== providerId) {
|
||||
for (const opt of modelMap.values()) {
|
||||
opt.qualifiedModel = `${routingPrefix}/${opt.id}`;
|
||||
}
|
||||
}
|
||||
rewriteQualifiedModelPrefix(modelMap, providerId, routingPrefix);
|
||||
|
||||
const displayName = (providerEntryName(providerId) ||
|
||||
getProviderDisplayName(providerId, null) ||
|
||||
|
||||
@@ -63,6 +63,57 @@ const EFFORT_TIER_ALIASES: Record<string, CanonicalEffort> = {
|
||||
max: "xhigh",
|
||||
};
|
||||
|
||||
/**
|
||||
* DeepSeek V4 exposes a native `max` reasoning tier ABOVE its `high` tier.
|
||||
*
|
||||
* Per https://api-docs.deepseek.com/api/create-chat-completion the accepted
|
||||
* `reasoning_effort` values are `low`, `high` and `max`, the default is `high`,
|
||||
* and **`medium` / `xhigh` are both mapped to `high` upstream**. Canonical
|
||||
* `max` collapses to `xhigh` (see EFFORT_TIER_ALIASES), so without this the
|
||||
* top tier is unreachable: `{"effort":"max"}` → `xhigh` → upstream `high`.
|
||||
*
|
||||
* Mirrors extendCodexGpt56EffortValues: expose the provider-native tier for
|
||||
* these models only, without widening the global request vocabulary.
|
||||
*/
|
||||
export function extendDeepSeekEffortValues(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
baseValues: readonly string[]
|
||||
): string[] {
|
||||
const values = [...baseValues];
|
||||
if (!isDeepSeekNativeMaxModel(provider, model)) return values;
|
||||
return values.includes("max") ? values : [...values, "max"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `<provider>/<model>` is a DeepSeek V4 model served by the native
|
||||
* DeepSeek provider (registry id `deepseek`, alias `ds`).
|
||||
*
|
||||
* Deliberately scoped to the native provider: routed namespaces such as
|
||||
* `openrouter/deepseek/...` or `tllm/deepseek_v4` terminate at a different
|
||||
* upstream whose accepted effort vocabulary we do not control.
|
||||
*/
|
||||
export function isDeepSeekNativeMaxModel(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined
|
||||
): boolean {
|
||||
const rawModel = model?.trim().toLowerCase();
|
||||
if (!rawModel) return false;
|
||||
|
||||
// The provider is not always resolved yet at the point the canonical request
|
||||
// params are folded in (see chat.ts), so accept either an explicit provider or
|
||||
// a `<prefix>/<model>` id carrying the native DeepSeek prefix.
|
||||
const prefixMatch = rawModel.match(/^(deepseek|ds)\//);
|
||||
const normalizedProvider = provider?.trim().toLowerCase() || prefixMatch?.[1];
|
||||
if (normalizedProvider !== "deepseek" && normalizedProvider !== "ds") return false;
|
||||
|
||||
const normalizedModel = rawModel.replace(/^(?:deepseek|ds)\//, "");
|
||||
if (!normalizedModel) return false;
|
||||
return /^deepseek-v4-(?:pro|flash)(?:-(?:none|minimal|low|medium|high|xhigh|max))?$/.test(
|
||||
normalizedModel
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary effort value onto the canonical vocabulary. Accepts the canonical
|
||||
* values plus the UI tier synonyms (`extra`/`max` → `xhigh`), case-insensitively. Returns
|
||||
@@ -100,6 +151,11 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Read a request body's `model` field when it is a usable string. */
|
||||
function asModelId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the canonical `effort` / `thinking` request params onto the per-provider reasoning
|
||||
* fields the existing translators already consume (`reasoning_effort`, `reasoning.effort`,
|
||||
@@ -112,10 +168,17 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
* - An explicit object-shaped `thinking` (the Anthropic `{ type, budget_tokens }` config)
|
||||
* is never overwritten by the canonical boolean `thinking`.
|
||||
*/
|
||||
export function normalizeReasoningRequest<T>(body: T): T {
|
||||
export function normalizeReasoningRequest<T>(body: T, provider?: string | null): T {
|
||||
if (!isPlainObject(body)) return body;
|
||||
|
||||
const canonicalEffort = normalizeEffort(body.effort);
|
||||
// DeepSeek V4 has a native `max` tier above `high`. Canonical `max` normally
|
||||
// collapses to `xhigh`, which DeepSeek maps back down to `high` — so preserve
|
||||
// the literal value for those models instead of round-tripping it away.
|
||||
const rawEffort = typeof body.effort === "string" ? body.effort.trim().toLowerCase() : undefined;
|
||||
const canonicalEffort =
|
||||
rawEffort === "max" && isDeepSeekNativeMaxModel(provider, asModelId(body.model))
|
||||
? ("max" as const)
|
||||
: normalizeEffort(body.effort);
|
||||
const canonicalThinking = body.thinking;
|
||||
const hasCanonicalThinkingBool = typeof canonicalThinking === "boolean";
|
||||
|
||||
|
||||
@@ -32,7 +32,12 @@ function classifyAutoModel(
|
||||
const recognizedBuiltInAuto =
|
||||
model === "auto" || Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, model);
|
||||
if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, model)) {
|
||||
return { variant: AUTO_TEMPLATE_VARIANTS[model], recognizedBuiltInAuto: true };
|
||||
// auto/best-free must carry spec.tier="free" so virtualFactory applies the
|
||||
// free-tier candidate filter (excludes paid backends). Mirrors the
|
||||
// hardcoded spec in builtinCatalog.ts:createBuiltinAutoCombo. Without this,
|
||||
// chat.ts routes auto/best-free as plain auto/cheap (no tier filter).
|
||||
const spec = model === "auto/best-free" ? { tier: "free" as const } : undefined;
|
||||
return { variant: AUTO_TEMPLATE_VARIANTS[model], spec, recognizedBuiltInAuto: true };
|
||||
}
|
||||
if (!model.startsWith("auto/")) return { recognizedBuiltInAuto };
|
||||
|
||||
|
||||
40
tests/unit/auto-best-free-tier-filter.test.ts
Normal file
40
tests/unit/auto-best-free-tier-filter.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Regression: `auto/best-free` must carry `spec.tier = "free"` so the
|
||||
* virtualFactory candidate filter (buildAutoCandidateFilter) excludes paid
|
||||
* backends from the pool.
|
||||
*
|
||||
* Root cause: `classifyAutoModel` in `src/sse/handlers/autoRouting.ts` returns
|
||||
* only `{ variant: "cheap" }` for `auto/best-free` (via AUTO_TEMPLATE_VARIANTS),
|
||||
* WITHOUT setting `spec.tier = "free"`. The sibling path `createBuiltinAutoCombo`
|
||||
* in `open-sse/services/autoCombo/builtinCatalog.ts` has a hardcoded special case
|
||||
* `modelStr === "auto/best-free" ? { tier: "free" as const } : undefined`, but
|
||||
* `chat.ts` routes through `resolveAutoRoutingState` → `createVirtualAutoCombo`
|
||||
* (autoRouting.ts), NOT through `createBuiltinAutoCombo`. So the chat path
|
||||
* skipped the tier filter entirely and `auto/best-free` behaved as plain
|
||||
* `auto/cheap`, allowing paid models (e.g. antigravity/gemini-3.6-flash-high)
|
||||
* to be selected from the full pool.
|
||||
*
|
||||
* classifyAutoModel() is module-private, so this exercises it through the public
|
||||
* resolveAutoRoutingState() — same pattern as auto-family-classification-8866.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveAutoRoutingState } from "../../src/sse/handlers/autoRouting.ts";
|
||||
|
||||
test("auto/best-free carries spec.tier='free' so the candidate filter excludes paid backends", async () => {
|
||||
const state = await resolveAutoRoutingState("auto/best-free");
|
||||
assert.equal(state.recognizedBuiltInAuto, true);
|
||||
assert.equal(state.variant, "cheap");
|
||||
assert.equal(
|
||||
state.spec?.tier,
|
||||
"free",
|
||||
"auto/best-free must carry spec.tier='free' to trigger the free-tier candidate filter in virtualFactory"
|
||||
);
|
||||
});
|
||||
|
||||
test("auto/best-coding does NOT carry a free tier spec (only auto/best-free is free-tier)", async () => {
|
||||
const state = await resolveAutoRoutingState("auto/best-coding");
|
||||
assert.equal(state.recognizedBuiltInAuto, true);
|
||||
assert.equal(state.variant, "coding");
|
||||
assert.notEqual(state.spec?.tier, "free");
|
||||
});
|
||||
@@ -118,6 +118,47 @@ test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves nested max", ()
|
||||
assert.equal((result as Record<string, unknown>).reasoning.summary, "auto");
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: Ollama Cloud maps registry model xhigh → max", () => {
|
||||
const log = makeLog();
|
||||
const body = {
|
||||
model: "glm-5.2",
|
||||
reasoning_effort: "xhigh",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const result = sanitizeReasoningEffortForProvider(body, "ollama-cloud", "glm-5.2", log) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.notEqual(result, body, "must return a new object when mutating");
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
assert.equal(result.model, "glm-5.2", "other fields preserved");
|
||||
assert.ok(
|
||||
log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /xhigh → max/.test(m)),
|
||||
"logs the xhigh → max mapping"
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: Ollama Cloud maps passthrough unknown model xhigh → max", () => {
|
||||
const log = makeLog();
|
||||
const body = {
|
||||
model: "some-future-glm-model",
|
||||
reasoning_effort: "xhigh",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const result = sanitizeReasoningEffortForProvider(
|
||||
body,
|
||||
"ollama-cloud",
|
||||
"some-future-glm-model",
|
||||
log
|
||||
) as Record<string, unknown>;
|
||||
assert.notEqual(result, body, "must return a new object when mutating");
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
assert.ok(
|
||||
log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /xhigh → max/.test(m)),
|
||||
"logs the xhigh → max mapping"
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek passes max through (new default)", () => {
|
||||
const log = makeLog();
|
||||
const body = {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
disableThinkingIfToolChoiceForced,
|
||||
enforceCacheControlLimit,
|
||||
ensureCacheControlOnLastUserMessage,
|
||||
normalizeCacheControlTtl,
|
||||
} from "../../open-sse/services/claudeCodeConstraints.ts";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -401,3 +402,66 @@ describe("ensureCacheControlOnLastUserMessage", () => {
|
||||
assert.doesNotThrow(() => ensureCacheControlOnLastUserMessage({}));
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// normalizeCacheControlTtl tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeCacheControlTtl", () => {
|
||||
it("defaults a missing ttl to 1h on the last system block", () => {
|
||||
const body = {
|
||||
system: [
|
||||
{ type: "text", text: "billing" },
|
||||
{ type: "text", text: "sentinel" },
|
||||
{ type: "text", text: "prompt", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
};
|
||||
|
||||
normalizeCacheControlTtl(body);
|
||||
|
||||
assert.deepEqual(body.system[2].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
});
|
||||
|
||||
it("does not touch a cache_control that already specifies a ttl", () => {
|
||||
const body = {
|
||||
system: [{ type: "text", text: "prompt", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
};
|
||||
|
||||
normalizeCacheControlTtl(body);
|
||||
|
||||
assert.deepEqual(body.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
});
|
||||
|
||||
it("defaults missing ttl in tools and message content blocks", () => {
|
||||
const body = {
|
||||
tools: [{ name: "bash", description: "run", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
normalizeCacheControlTtl(body);
|
||||
|
||||
assert.deepEqual(body.tools[0].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
assert.deepEqual(body.messages[0].content[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "1h",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves blocks without cache_control untouched", () => {
|
||||
const body = {
|
||||
system: [{ type: "text", text: "no cache_control here" }],
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => normalizeCacheControlTtl(body));
|
||||
assert.equal(body.system[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
it("handles a body with no system/tools/messages without throwing", () => {
|
||||
assert.doesNotThrow(() => normalizeCacheControlTtl({}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-pre
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { getComboBuilderOptions } = await import("../../src/lib/combos/builderOptions.ts");
|
||||
const { parseModel } = await import("../../open-sse/services/model.ts");
|
||||
|
||||
@@ -52,6 +53,29 @@ test("#2901 no-auth OpenCode combo models use the oc/ prefix (not opencode/)", a
|
||||
}
|
||||
});
|
||||
|
||||
test("#2901 configured OpenCode connections also use the oc/ prefix", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "opencode",
|
||||
authType: "apikey",
|
||||
name: "OpenCode Free test connection",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
const payload = await getComboBuilderOptions();
|
||||
const opencode = payload.providers.find(
|
||||
(provider) => provider.providerId === "opencode" && provider.connectionCount > 0
|
||||
);
|
||||
assert.ok(opencode, "configured OpenCode provider must appear in the combo builder");
|
||||
|
||||
const bigPickle = opencode.models.find((model) => model.id === "big-pickle");
|
||||
assert.ok(bigPickle, "big-pickle must be listed under the configured opencode provider");
|
||||
assert.equal(
|
||||
bigPickle.qualifiedModel,
|
||||
"oc/big-pickle",
|
||||
"configured opencode combo entries must use the 'oc/' routing alias"
|
||||
);
|
||||
});
|
||||
|
||||
test("#2901 the oc/ prefix actually resolves back to the no-auth opencode provider", () => {
|
||||
// Guards the premise: opencode/ misroutes to opencode-zen, oc/ is correct.
|
||||
assert.equal(parseModel("oc/big-pickle").provider, "opencode");
|
||||
|
||||
104
tests/unit/deepseek-native-max-effort.test.ts
Normal file
104
tests/unit/deepseek-native-max-effort.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* DeepSeek V4 exposes a native `max` reasoning tier that the canonical vocabulary erases.
|
||||
*
|
||||
* Per https://api-docs.deepseek.com/api/create-chat-completion the accepted
|
||||
* `reasoning_effort` values are `low`, `high` and `max`, the default is `high`, and
|
||||
* **`medium` / `xhigh` are both mapped to `high`** upstream. (The live API's 400 on an
|
||||
* invalid value enumerates the full accepted set:
|
||||
* `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`.)
|
||||
*
|
||||
* OmniRoute's canonical vocabulary is `none|low|medium|high|xhigh`, and `max` is an alias
|
||||
* that collapses onto `xhigh` (EFFORT_TIER_ALIASES). Since DeepSeek then maps `xhigh` back
|
||||
* down to `high`, a client sending `{"effort":"max"}` silently received **high** — the top
|
||||
* tier was unreachable through the canonical field.
|
||||
*
|
||||
* The fix mirrors the existing `extendCodexGpt56EffortValues` precedent: expose the
|
||||
* provider-native tier for these models only, without widening the global request
|
||||
* vocabulary for every other provider.
|
||||
*
|
||||
* Guards: A = `max` survives for native DeepSeek models; B = every other provider still
|
||||
* collapses `max`→`xhigh`; C = routed DeepSeek namespaces (openrouter/tllm) are NOT treated
|
||||
* as native; D = an explicit client `reasoning_effort` still wins; E = the catalog offers
|
||||
* `max` as an effort tier for native DeepSeek models.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
normalizeReasoningRequest,
|
||||
normalizeEffort,
|
||||
isDeepSeekNativeMaxModel,
|
||||
extendDeepSeekEffortValues,
|
||||
CANONICAL_EFFORT_VALUES,
|
||||
} = await import("../../src/shared/reasoning/effortStandardization.ts");
|
||||
|
||||
test("A: canonical effort `max` survives for native DeepSeek V4 models", () => {
|
||||
for (const model of [
|
||||
"ds/deepseek-v4-pro",
|
||||
"ds/deepseek-v4-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
]) {
|
||||
const out = normalizeReasoningRequest({ model, effort: "max" }) as Record<string, unknown>;
|
||||
assert.equal(
|
||||
out.reasoning_effort,
|
||||
"max",
|
||||
`${model} must reach DeepSeek's native max tier, not the down-mapped xhigh`
|
||||
);
|
||||
assert.deepEqual((out.reasoning as Record<string, unknown>).effort, "max");
|
||||
}
|
||||
});
|
||||
|
||||
test("A2: the provider can also be supplied explicitly (model id without prefix)", () => {
|
||||
const out = normalizeReasoningRequest(
|
||||
{ model: "deepseek-v4-pro", effort: "max" },
|
||||
"deepseek"
|
||||
) as Record<string, unknown>;
|
||||
assert.equal(out.reasoning_effort, "max");
|
||||
});
|
||||
|
||||
test("B: `max` still collapses to `xhigh` for every other provider", () => {
|
||||
for (const model of ["openai/gpt-5", "anthropic/claude-opus-4-8", "z-ai/glm-5.2"]) {
|
||||
const out = normalizeReasoningRequest({ model, effort: "max" }) as Record<string, unknown>;
|
||||
assert.equal(out.reasoning_effort, "xhigh", `${model} must keep the canonical collapse`);
|
||||
}
|
||||
// The global vocabulary itself is unchanged.
|
||||
assert.deepEqual([...CANONICAL_EFFORT_VALUES], ["none", "low", "medium", "high", "xhigh"]);
|
||||
assert.equal(normalizeEffort("max"), "xhigh");
|
||||
});
|
||||
|
||||
test("C: routed DeepSeek namespaces are not treated as the native provider", () => {
|
||||
// These terminate at a different upstream whose effort vocabulary we do not control.
|
||||
for (const model of [
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
"tllm/deepseek_v4",
|
||||
"oc/deepseek-v4-flash-free",
|
||||
]) {
|
||||
assert.equal(isDeepSeekNativeMaxModel(null, model), false, `${model} is not native`);
|
||||
const out = normalizeReasoningRequest({ model, effort: "max" }) as Record<string, unknown>;
|
||||
assert.equal(out.reasoning_effort, "xhigh");
|
||||
}
|
||||
});
|
||||
|
||||
test("D: an explicit client reasoning_effort still wins over canonical effort", () => {
|
||||
const out = normalizeReasoningRequest({
|
||||
model: "ds/deepseek-v4-flash",
|
||||
effort: "max",
|
||||
reasoning_effort: "low",
|
||||
}) as Record<string, unknown>;
|
||||
assert.equal(out.reasoning_effort, "low", "explicit client intent must be preserved");
|
||||
});
|
||||
|
||||
test("E: catalog effort tiers advertise `max` for native DeepSeek models only", () => {
|
||||
const base = [...CANONICAL_EFFORT_VALUES];
|
||||
|
||||
const deepseekTiers = extendDeepSeekEffortValues("deepseek", "deepseek-v4-pro", base);
|
||||
assert.ok(deepseekTiers.includes("max"), "native DeepSeek must advertise the max tier");
|
||||
|
||||
const otherTiers = extendDeepSeekEffortValues("openai", "gpt-5", base);
|
||||
assert.ok(!otherTiers.includes("max"), "other providers must be untouched");
|
||||
|
||||
// Idempotent: never duplicate an already-present tier.
|
||||
const twice = extendDeepSeekEffortValues("ds", "deepseek-v4-flash", deepseekTiers);
|
||||
assert.equal(twice.filter((t) => t === "max").length, 1);
|
||||
});
|
||||
46
tests/unit/executor-xai-chat-to-responses-10165.test.ts
Normal file
46
tests/unit/executor-xai-chat-to-responses-10165.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { XaiExecutor } from "../../open-sse/executors/xai.ts";
|
||||
import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts";
|
||||
|
||||
test("#10165 xai-oauth grok-4.5 is tagged openai-responses", () => {
|
||||
assert.equal(getModelTargetFormat("xai-oauth", "grok-4.5"), "openai-responses");
|
||||
assert.equal(getModelTargetFormat("xao", "grok-4.5"), "openai-responses");
|
||||
});
|
||||
|
||||
test("#10165 XaiExecutor converts chat body to Responses fields for grok-4.5", () => {
|
||||
const executor = new XaiExecutor("xai-oauth");
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
messages: [{ role: "user", content: "say ok" }],
|
||||
max_tokens: 16,
|
||||
response_format: { type: "json_object" },
|
||||
};
|
||||
const out = executor.transformRequest("grok-4.5", body, false, {
|
||||
accessToken: "test",
|
||||
} as never) as Record<string, unknown>;
|
||||
|
||||
assert.ok(Array.isArray(out.input), "messages must become input");
|
||||
assert.equal(out.messages, undefined);
|
||||
assert.equal(out.max_output_tokens, 16);
|
||||
assert.equal(out.max_tokens, undefined);
|
||||
assert.equal(out.response_format, undefined);
|
||||
assert.ok(out.text && typeof out.text === "object");
|
||||
});
|
||||
|
||||
test("#10165 XaiExecutor maps max_tokens when input already present", () => {
|
||||
const executor = new XaiExecutor("xai-oauth");
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: "say ok",
|
||||
max_tokens: 32,
|
||||
};
|
||||
const out = executor.transformRequest("grok-4.5", body, false, {
|
||||
accessToken: "test",
|
||||
} as never) as Record<string, unknown>;
|
||||
|
||||
assert.equal(out.input, "say ok");
|
||||
assert.equal(out.max_output_tokens, 32);
|
||||
assert.equal(out.max_tokens, undefined);
|
||||
});
|
||||
84
tests/unit/gemini-codex-encrypted-tool-schema.test.ts
Normal file
84
tests/unit/gemini-codex-encrypted-tool-schema.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* antigravity/gemini returned [400] "Invalid JSON payload received.
|
||||
* Unknown name \"encrypted\" at 'request.tools[0].function_declarations[11].
|
||||
* parameters.properties[0].value': Cannot find field."
|
||||
*
|
||||
* Root cause: Codex's multi-agent collaboration tools (spawn_agent /
|
||||
* send_message / followup_task) mark their `message` parameter schema with a
|
||||
* non-standard `encrypted: true` annotation (JsonSchema::with_encrypted).
|
||||
* `encrypted` was NOT listed in `GEMINI_UNSUPPORTED_SCHEMA_KEYS`, so
|
||||
* `cleanJSONSchemaForAntigravity` left it in the function-declaration
|
||||
* parameters, and the Gemini/antigravity upstream (OpenAPI 3.0 schema subset)
|
||||
* rejects the unrecognized keyword with a hard 400. This only shows up when
|
||||
* routing Codex to an agy/Antigravity model because the OpenAI passthrough
|
||||
* path does not validate tool schemas.
|
||||
*
|
||||
* Fix: add `encrypted` to the unsupported-keys set so it is stripped at every level.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
cleanJSONSchemaForAntigravity,
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYS,
|
||||
} from "../../open-sse/translator/helpers/geminiHelper.ts";
|
||||
import { openaiToGeminiRequest } from "../../open-sse/translator/request/openai-to-gemini.ts";
|
||||
|
||||
test("encrypted is stripped at all levels for antigravity/gemini schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Message text to send to the target agent.",
|
||||
encrypted: true,
|
||||
},
|
||||
task_name: { type: "string" },
|
||||
},
|
||||
required: ["message"],
|
||||
};
|
||||
|
||||
const cleaned = JSON.stringify(cleanJSONSchemaForAntigravity(schema));
|
||||
|
||||
assert.ok(!cleaned.includes("encrypted"), "encrypted must be removed");
|
||||
assert.ok(cleaned.includes("message"), "unrelated properties must be preserved");
|
||||
assert.ok(cleaned.includes("task_name"), "unrelated properties must be preserved");
|
||||
});
|
||||
|
||||
test("encrypted is in GEMINI_UNSUPPORTED_SCHEMA_KEYS", () => {
|
||||
assert.ok(GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("encrypted"));
|
||||
});
|
||||
|
||||
test("OpenAI -> Gemini request strips encrypted from Codex collaboration tool parameters", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "collaboration.send_message",
|
||||
description: "send",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Message text", encrypted: true },
|
||||
recipient: { type: "string" },
|
||||
},
|
||||
required: ["message", "recipient"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as {
|
||||
tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>;
|
||||
};
|
||||
|
||||
const parameters = result.tools?.[0]?.functionDeclarations?.[0]?.parameters;
|
||||
assert.ok(parameters, "expected a translated function declaration");
|
||||
assert.ok(
|
||||
!JSON.stringify(parameters).includes("encrypted"),
|
||||
"encrypted must not reach the upstream request"
|
||||
);
|
||||
});
|
||||
135
tests/unit/synced-model-delete-custom-sibling-tombstone.test.ts
Normal file
135
tests/unit/synced-model-delete-custom-sibling-tombstone.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Deleting a CUSTOM model must not tombstone a same-id SYNCED model.
|
||||
*
|
||||
* Reported flow (provider `deepseek`, models `deepseek-v4-flash` / `deepseek-v4-pro`):
|
||||
* 1. Eye-hide both synced models -> `isHidden:true` (no `isDeleted`)
|
||||
* 2. Manually ADD custom models with the SAME ids
|
||||
* 3. DELETE the custom models just created
|
||||
* 4. The ORIGINAL synced models are gone too, yet the UI still lists them
|
||||
*
|
||||
* Step 3 is the bug. `DELETE /api/provider-models` resolves `provider` + `model`
|
||||
* only — it has no notion of WHICH of the two same-id rows the operator clicked.
|
||||
* It unconditionally runs both removals and then, because the synced removal
|
||||
* reports `true`, writes the `isDeleted:true` tombstone. From then on
|
||||
* `replaceSyncedAvailableModelsForConnection` filters the id out of every
|
||||
* re-import (`getModelIsDeleted`), so the provider can never resync: the sync
|
||||
* endpoint keeps reporting `added: N` while the catalog stays empty, and
|
||||
* `/v1/models` never lists the models again.
|
||||
*
|
||||
* The eye-hide in step 1 is what makes this reachable in practice — a hidden
|
||||
* model stays in the synced store (#3782), so the id exists in BOTH stores at
|
||||
* the same time and one DELETE hits both.
|
||||
*
|
||||
* Guards: A = deleting a custom model leaves a same-id synced sibling intact and
|
||||
* re-importable; B = deleting a synced-only model still tombstones (#3199 must
|
||||
* not regress).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Hermetic DB: this test writes into the `customModels`, `syncedAvailableModels`
|
||||
// and `modelCompatOverrides` namespaces. Without an isolated DATA_DIR it would
|
||||
// leak that state into the shared dev/CI database, so a SECOND run would see
|
||||
// stale tombstones and the preconditions would fail. Point DATA_DIR at a
|
||||
// throwaway dir before any import that opens the SQLite handle.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-delete-sibling-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
// The DELETE route is auth-gated; with no INITIAL_PASSWORD and no stored
|
||||
// credential, `isAuthenticated` resolves true for local management calls.
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const providerModelsRoute = await import("../../src/app/api/provider-models/route.ts");
|
||||
|
||||
const PROVIDER = "deepseek";
|
||||
const CONNECTION = "conn-delete-sibling";
|
||||
const FLASH = "deepseek-v4-flash";
|
||||
const PRO = "deepseek-v4-pro";
|
||||
|
||||
test.after(() => {
|
||||
// Release the SQLite handle so the Node test runner can exit, then remove the
|
||||
// throwaway DATA_DIR (CLAUDE.md "Database Handles in Tests").
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Invoke the real DELETE handler so the test tracks production behavior. */
|
||||
async function deleteProviderModel(provider: string, modelId: string) {
|
||||
const url =
|
||||
`http://localhost/api/provider-models` +
|
||||
`?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(modelId)}`;
|
||||
const response = await providerModelsRoute.DELETE(new Request(url, { method: "DELETE" }));
|
||||
assert.equal(response.status, 200, "DELETE /api/provider-models should succeed");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test("A: deleting a custom model leaves a same-id synced sibling re-importable", async () => {
|
||||
core.resetDbInstance();
|
||||
|
||||
// Step 1 — provider sync brings both models in; operator eye-hides one.
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: FLASH, name: FLASH },
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
modelsDb.mergeModelCompatOverride(PROVIDER, FLASH, { isHidden: true });
|
||||
assert.equal(
|
||||
modelsDb.getModelIsDeleted(PROVIDER, FLASH),
|
||||
false,
|
||||
"precondition: eye-hide must not mark the model deleted"
|
||||
);
|
||||
|
||||
// Step 2 — operator manually adds a custom model with the SAME id.
|
||||
await modelsDb.addCustomModel(PROVIDER, FLASH, FLASH);
|
||||
|
||||
// Step 3 — operator deletes the custom model they just created.
|
||||
await deleteProviderModel(PROVIDER, FLASH);
|
||||
|
||||
// Step 4 — the synced sibling must NOT have been tombstoned.
|
||||
assert.equal(
|
||||
modelsDb.getModelIsDeleted(PROVIDER, FLASH),
|
||||
false,
|
||||
"deleting the custom model must not write an isDeleted tombstone for the synced sibling"
|
||||
);
|
||||
|
||||
// The decisive assertion: a later re-sync must bring the model back.
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: FLASH, name: FLASH },
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
const ids = (await modelsDb.getSyncedAvailableModels(PROVIDER)).map((m: { id: string }) => m.id);
|
||||
assert.ok(
|
||||
ids.includes(FLASH),
|
||||
`re-import must restore the synced model; got [${ids.join(", ")}]`
|
||||
);
|
||||
});
|
||||
|
||||
test("B: deleting a synced-only model still tombstones it (#3199 must not regress)", async () => {
|
||||
core.resetDbInstance();
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
assert.equal(modelsDb.getModelIsDeleted(PROVIDER, PRO), false, "precondition: not yet deleted");
|
||||
|
||||
// No custom row exists for this id — this is a real trash/delete.
|
||||
await deleteProviderModel(PROVIDER, PRO);
|
||||
|
||||
assert.equal(
|
||||
modelsDb.getModelIsDeleted(PROVIDER, PRO),
|
||||
true,
|
||||
"a synced-only delete must still write the isDeleted tombstone"
|
||||
);
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
const ids = (await modelsDb.getSyncedAvailableModels(PROVIDER)).map((m: { id: string }) => m.id);
|
||||
assert.ok(
|
||||
!ids.includes(PRO),
|
||||
`a deleted model must stay dropped across re-import; got [${ids.join(", ")}]`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user