mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
fix(catalog): scope combo reasoning efforts by connection (#10723)
Merged — locally validated (72/72 focused tests, typecheck:core clean, all static gates green) after resolving base-drift conflicts (catalog.ts cooperative-yield insertion point, modelMetadataRegistry.ts snapshot-param signature). CI's red checks (Unit Tests fast-path shards, Fast Quality Gates, Docs Gates) are confirmed PRE-EXISTING base-red on the pure release tip — reproduced tests/unit/db-driver-bundling-externals.test.ts, tests/unit/model-catalog-runtime-invalidation.test.ts and others failing identically against origin/release/v3.8.50 with zero PR content, unrelated to this change. Thanks for the design and for absorbing #10724's value here — great work on both review rounds!
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(catalog):** derive combo reasoning-effort tiers from the exact runtime-selectable connection scope, intersecting dynamic, pinned, allowlisted, and compatible provider-node evidence while failing closed on unknown capabilities.
|
||||
@@ -11,6 +11,7 @@ export const GROK_BUILD_TOKEN_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/token`;
|
||||
export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "0.2.106";
|
||||
export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 256_000;
|
||||
export const GROK_BUILD_DEFAULT_REASONING_EFFORT = "high";
|
||||
export const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = Object.freeze(["low", "medium", "high"]);
|
||||
export const GROK_BUILD_CLIENT_IDENTIFIER = "grok-shell";
|
||||
export const GROK_BUILD_TOKEN_AUTH = "xai-grok-cli";
|
||||
export const GROK_BUILD_REASONING_INCLUDE = "reasoning.encrypted_content";
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
GROK_BUILD_DEFAULT_REASONING_EFFORT,
|
||||
GROK_BUILD_REASONING_INCLUDE,
|
||||
GROK_BUILD_RESPONSES_URL,
|
||||
GROK_BUILD_SUPPORTED_REASONING_EFFORTS,
|
||||
GROK_BUILD_TOKEN_URL,
|
||||
} from "../config/grokBuild.ts";
|
||||
import { resolvePublicCred } from "../utils/publicCreds.ts";
|
||||
import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts";
|
||||
|
||||
const GROK_BUILD_MAX_TOOLS = 200;
|
||||
const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high"]);
|
||||
const GROK_BUILD_REASONING_EFFORT_SET = new Set(GROK_BUILD_SUPPORTED_REASONING_EFFORTS);
|
||||
const GROK_BUILD_REFRESH_MAX_ATTEMPTS = 3;
|
||||
const GROK_BUILD_REFRESH_MIN_DELAY_MS = 200;
|
||||
const GROK_BUILD_TERMINAL_REFRESH_ERRORS = new Set(["invalid_grant", "invalid_client"]);
|
||||
@@ -33,7 +34,6 @@ const GROK_BUILD_UNSUPPORTED_PARAMS = [
|
||||
"reasoning_effort",
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Grok Build's cli-chat-proxy is stricter about Responses `function_call_output.output`
|
||||
* than OpenAI's Responses API. Agent tool results can contain truncated / incomplete
|
||||
@@ -128,7 +128,7 @@ function normalizeGrokBuildReasoning(
|
||||
): Record<string, unknown> | null {
|
||||
const reasoning = asRequestRecord(value);
|
||||
const hasExplicitEffort = Object.prototype.hasOwnProperty.call(reasoning, "effort");
|
||||
if (!GROK_BUILD_SUPPORTED_REASONING_EFFORTS.has(String(reasoning.effort))) {
|
||||
if (!GROK_BUILD_REASONING_EFFORT_SET.has(String(reasoning.effort))) {
|
||||
delete reasoning.effort;
|
||||
}
|
||||
if (model === "grok-composer-2.5-fast") {
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
type PricingCatalogProvider,
|
||||
} from "@/lib/modelCapabilityOverrideTargets";
|
||||
|
||||
type ModelOverrideKey = "context_length" | "max_input_tokens" | "max_output_tokens";
|
||||
type ModelOverrideKey =
|
||||
"context_length" | "max_input_tokens" | "max_output_tokens" | "reasoning_efforts";
|
||||
type ModelOverrideValue = number | string[];
|
||||
type StatusTone = "success" | "error" | "info";
|
||||
|
||||
type ModelOverrideTarget = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget;
|
||||
@@ -22,7 +24,7 @@ interface PricingCatalogModel {
|
||||
interface ModelCapabilityOverride {
|
||||
target: string;
|
||||
key: ModelOverrideKey;
|
||||
value: number;
|
||||
value: ModelOverrideValue;
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
@@ -70,7 +72,7 @@ function useModelCapabilityOverridesData() {
|
||||
}, [showStatus, t]);
|
||||
|
||||
const saveOverride = useCallback(
|
||||
async (target: string, key: ModelOverrideKey, value: number) => {
|
||||
async (target: string, key: ModelOverrideKey, value: number | string) => {
|
||||
try {
|
||||
const response = await fetch("/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
@@ -150,7 +152,7 @@ function ModelCapabilityOverridesPanel({
|
||||
}: {
|
||||
targets: ModelOverrideTarget[];
|
||||
overrides: ModelCapabilityOverride[];
|
||||
onSave: (target: string, key: ModelOverrideKey, value: number) => void;
|
||||
onSave: (target: string, key: ModelOverrideKey, value: number | string) => void;
|
||||
onRemove: (target: string, key: ModelOverrideKey) => void;
|
||||
}) {
|
||||
const [selectedTarget, setSelectedTarget] = useState("");
|
||||
@@ -294,7 +296,7 @@ function ModelOverrideEditor({
|
||||
activeOverrides: ModelCapabilityOverride[];
|
||||
activeTarget: string;
|
||||
onRemove: (target: string, key: ModelOverrideKey) => void;
|
||||
onSave: (target: string, key: ModelOverrideKey, value: number) => void;
|
||||
onSave: (target: string, key: ModelOverrideKey, value: number | string) => void;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
return (
|
||||
@@ -316,13 +318,18 @@ function ModelOverrideForm({
|
||||
onSave,
|
||||
}: {
|
||||
activeTarget: string;
|
||||
onSave: (target: string, key: ModelOverrideKey, value: number) => void;
|
||||
onSave: (target: string, key: ModelOverrideKey, value: number | string) => void;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [key, setKey] = useState<ModelOverrideKey>("context_length");
|
||||
const [value, setValue] = useState("");
|
||||
const isReasoningEfforts = key === "reasoning_efforts";
|
||||
const numericValue = Number(value);
|
||||
const saveDisabled = !activeTarget || !Number.isInteger(numericValue) || numericValue <= 0;
|
||||
const saveDisabled =
|
||||
!activeTarget ||
|
||||
(isReasoningEfforts
|
||||
? value.length === 0
|
||||
: !Number.isInteger(numericValue) || numericValue <= 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
@@ -334,14 +341,19 @@ function ModelOverrideForm({
|
||||
<option value="context_length">context_length</option>
|
||||
<option value="max_input_tokens">max_input_tokens</option>
|
||||
<option value="max_output_tokens">max_output_tokens</option>
|
||||
<option value="reasoning_efforts">reasoning_efforts</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
type={isReasoningEfforts ? "text" : "number"}
|
||||
min={isReasoningEfforts ? undefined : "1"}
|
||||
step={isReasoningEfforts ? undefined : "1"}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={t("modelOverrideValuePlaceholder")}
|
||||
placeholder={t(
|
||||
isReasoningEfforts
|
||||
? "modelOverrideReasoningEffortsPlaceholder"
|
||||
: "modelOverrideValuePlaceholder"
|
||||
)}
|
||||
className="flex-1 px-3 py-2 text-xs bg-bg-base border border-border rounded-md focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<Button
|
||||
@@ -349,7 +361,7 @@ function ModelOverrideForm({
|
||||
size="sm"
|
||||
disabled={saveDisabled}
|
||||
onClick={() => {
|
||||
onSave(activeTarget, key, numericValue);
|
||||
onSave(activeTarget, key, isReasoningEfforts ? value : numericValue);
|
||||
setValue("");
|
||||
}}
|
||||
>
|
||||
@@ -398,7 +410,9 @@ function ModelOverrideRow({
|
||||
<div className="px-3 py-2 flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono px-1.5 py-0.5 rounded bg-bg-subtle">{override.key}</span>
|
||||
<span className="font-semibold tabular-nums">{override.value}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{Array.isArray(override.value) ? override.value.join(", ") : override.value}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { resolveProviderAlias } from "@omniroute/open-sse/services/model.ts";
|
||||
import { parseReasoningEffortsOverride } from "@/shared/reasoning/reasoningEffortsOverride";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
listModelCapabilityOverrides,
|
||||
removeModelCapabilityOverride,
|
||||
setModelCapabilityOverride,
|
||||
type ModelCapabilityOverride,
|
||||
type ModelCapabilityOverrideKey,
|
||||
} from "@/lib/db/modelCapabilityOverrides";
|
||||
import {
|
||||
@@ -16,9 +16,21 @@ import {
|
||||
} from "@/lib/db/modelContextOverrides";
|
||||
import { getProviderPrefixIndex, type ProviderPrefixEntry } from "@/lib/providerNodePrefixes";
|
||||
|
||||
const overrideKeySchema = z.enum(["context_length", "max_input_tokens", "max_output_tokens"]);
|
||||
const overrideKeySchema = z.enum([
|
||||
"context_length",
|
||||
"max_input_tokens",
|
||||
"max_output_tokens",
|
||||
"reasoning_efforts",
|
||||
]);
|
||||
type PublicOverrideKey = z.infer<typeof overrideKeySchema>;
|
||||
type PublicOverride = Omit<ModelCapabilityOverride, "key"> & { key: PublicOverrideKey };
|
||||
type PublicOverride = {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
target: string;
|
||||
key: PublicOverrideKey;
|
||||
value: number | string[];
|
||||
refreshedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One-time per-request snapshot of the provider-node prefix index. Loaded once
|
||||
@@ -79,12 +91,28 @@ async function listPublicOverrides(
|
||||
.sort((left, right) => right.refreshedAt.localeCompare(left.refreshedAt));
|
||||
}
|
||||
|
||||
const upsertOverrideSchema = z.object({
|
||||
target: z.string().min(3),
|
||||
key: overrideKeySchema,
|
||||
value: z.coerce.number().int().positive(),
|
||||
const reasoningEffortsValueSchema = z.string().transform((value, context) => {
|
||||
const parsed = parseReasoningEffortsOverride(value);
|
||||
if (!parsed.ok) {
|
||||
context.addIssue({ code: "custom", message: parsed.error });
|
||||
return z.NEVER;
|
||||
}
|
||||
return parsed.efforts;
|
||||
});
|
||||
|
||||
const upsertOverrideSchema = z.discriminatedUnion("key", [
|
||||
z.object({
|
||||
target: z.string().min(3),
|
||||
key: z.enum(["context_length", "max_input_tokens", "max_output_tokens"]),
|
||||
value: z.coerce.number().int().positive(),
|
||||
}),
|
||||
z.object({
|
||||
target: z.string().min(3),
|
||||
key: z.literal("reasoning_efforts"),
|
||||
value: reasoningEffortsValueSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Canonicalize a public `<prefix>/<model>` target to `<internalNodeId>/<model>`
|
||||
* so the override is stored where runtime lookup reads it. Mirrors runtime
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
|
||||
getGrokBuildModelsHeaders,
|
||||
GROK_BUILD_MODELS_URL,
|
||||
GROK_BUILD_SUPPORTED_REASONING_EFFORTS,
|
||||
} from "@omniroute/open-sse/config/grokBuild.ts";
|
||||
import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
|
||||
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
|
||||
@@ -220,7 +221,10 @@ function getGrokBuildModelItems(data: unknown): unknown[] {
|
||||
return Array.isArray(envelope.models) ? envelope.models : [];
|
||||
}
|
||||
|
||||
function hasGrokBuildReasoning(model: GrokBuildModelRecord, metadata: GrokBuildModelRecord) {
|
||||
function hasGrokBuildReasoning(
|
||||
model: GrokBuildModelRecord,
|
||||
metadata: GrokBuildModelRecord
|
||||
): boolean {
|
||||
const flags = [
|
||||
model.supportsReasoningEffort,
|
||||
model.supports_reasoning_effort,
|
||||
@@ -245,6 +249,35 @@ function hasGrokBuildReasoning(model: GrokBuildModelRecord, metadata: GrokBuildM
|
||||
);
|
||||
}
|
||||
|
||||
function getGrokBuildReasoningEfforts(
|
||||
model: GrokBuildModelRecord,
|
||||
metadata: GrokBuildModelRecord
|
||||
): string[] {
|
||||
const supported = new Set(GROK_BUILD_SUPPORTED_REASONING_EFFORTS);
|
||||
const effortLists = [
|
||||
model.reasoningEfforts,
|
||||
model.reasoning_efforts,
|
||||
metadata.reasoningEfforts,
|
||||
metadata.reasoning_efforts,
|
||||
];
|
||||
const hasExplicitEffortList = effortLists.some((value) => Array.isArray(value));
|
||||
const discovered = effortLists
|
||||
.flatMap((value) => (Array.isArray(value) ? value : []))
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => supported.has(value));
|
||||
if (hasExplicitEffortList) return [...new Set(discovered)];
|
||||
|
||||
const singleEffort = grokBuildString(
|
||||
model.reasoningEffort,
|
||||
model.reasoning_effort,
|
||||
metadata.reasoningEffort,
|
||||
metadata.reasoning_effort
|
||||
)?.toLowerCase();
|
||||
if (singleEffort && supported.has(singleEffort)) return [singleEffort];
|
||||
return hasGrokBuildReasoning(model, metadata) ? [...GROK_BUILD_SUPPORTED_REASONING_EFFORTS] : [];
|
||||
}
|
||||
|
||||
function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null {
|
||||
const model = asGrokBuildRecord(value);
|
||||
const metadata = asGrokBuildRecord(model._meta);
|
||||
@@ -285,6 +318,8 @@ function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null {
|
||||
model.max_completion_tokens
|
||||
);
|
||||
const description = grokBuildString(model.description);
|
||||
const supportsThinking = hasGrokBuildReasoning(model, metadata);
|
||||
const supportedThinkingEfforts = getGrokBuildReasoningEfforts(model, metadata);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -293,7 +328,8 @@ function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null {
|
||||
...(description ? { description } : {}),
|
||||
inputTokenLimit,
|
||||
...(outputTokenLimit ? { outputTokenLimit } : {}),
|
||||
...(hasGrokBuildReasoning(model, metadata) ? { supportsThinking: true } : {}),
|
||||
...(supportsThinking ? { supportsThinking: true } : {}),
|
||||
...(supportedThinkingEfforts.length > 0 ? { supportedThinkingEfforts } : {}),
|
||||
apiFormat: "responses",
|
||||
supportedEndpoints: ["responses"],
|
||||
};
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
prepareBuiltinAutoComboInputs,
|
||||
isPaidTierAutoId,
|
||||
} from "@omniroute/open-sse/services/autoCombo/builtinCatalog";
|
||||
import type { SyncedAvailableModel } from "@/lib/db/models";
|
||||
import {
|
||||
getSyncedAvailableModelsByConnection,
|
||||
SYNCED_AVAILABLE_MODELS_MALFORMED,
|
||||
type SyncedAvailableModel,
|
||||
} from "@/lib/db/models";
|
||||
import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog";
|
||||
import { getModelCatalogCacheVersion } from "@/lib/db/readCache";
|
||||
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
||||
@@ -58,9 +62,11 @@ import {
|
||||
getCatalogDiagnosticsHeaders,
|
||||
type CatalogEnrichmentSnapshot,
|
||||
} from "@/lib/modelMetadataRegistry";
|
||||
import { createModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot";
|
||||
import { getModelsDevPricing, getSyncedCapability } from "@/lib/modelsDevSync";
|
||||
import { getModelSpec } from "@/shared/constants/modelSpecs";
|
||||
import { getModelsCatalogPrefixMode } from "@/shared/utils/featureFlags";
|
||||
import { buildReservedPrefixes, selectCompatibleNodeForPrefix } from "@/lib/providerNodePrefixes";
|
||||
import { applyCatalogPostFilters, finalizeCatalogResponse } from "./catalogResponse";
|
||||
import {
|
||||
isNoAuthProviderBlocked,
|
||||
@@ -82,6 +88,8 @@ import {
|
||||
maybeOmitCatalogModelName,
|
||||
getThinkingCapabilityFields,
|
||||
mergeComboCapabilities,
|
||||
getConnectionScopedEffortTiers,
|
||||
type ConnectionScopedReasoningCatalog,
|
||||
} from "./catalogHelpers";
|
||||
import {
|
||||
qualifyOpenRouterModelId,
|
||||
@@ -271,6 +279,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// #9147: yield after auth check before DB initialization prologue
|
||||
await yieldCatalogBuildTurn();
|
||||
|
||||
const capabilityResolutionSnapshot = createModelCapabilityResolutionSnapshot();
|
||||
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
|
||||
const _qp = new URL(request.url).searchParams.get("prefix");
|
||||
const prefixMode =
|
||||
@@ -323,6 +332,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
|
||||
// Build map of provider node ID to prefix and type for compatible providers
|
||||
const providerIdToPrefix: Record<string, string> = {};
|
||||
const providerNodeIdByPrefix: Record<string, string> = {};
|
||||
const nodeIdToProviderType: Record<string, string> = {};
|
||||
for (const node of providerNodes) {
|
||||
const resolvedPrefix =
|
||||
@@ -340,6 +350,12 @@ async function buildUnifiedModelsResponseCore(
|
||||
nodeIdToProviderType[node.id] = node.type;
|
||||
}
|
||||
}
|
||||
const reservedProviderPrefixes = buildReservedPrefixes();
|
||||
for (const prefix of new Set(Object.values(providerIdToPrefix))) {
|
||||
if (reservedProviderPrefixes.has(prefix)) continue;
|
||||
const winner = selectCompatibleNodeForPrefix(providerNodes, prefix);
|
||||
if (winner?.id) providerNodeIdByPrefix[prefix] = winner.id;
|
||||
}
|
||||
|
||||
// #8327: `resolveCanonicalProviderId`/`canonicalProviderId` only know the static
|
||||
// AI_PROVIDERS/PROVIDER_MODELS alias maps, so a compatible-provider node (whose raw
|
||||
@@ -453,8 +469,41 @@ async function buildUnifiedModelsResponseCore(
|
||||
const getProviderPrefixes = (providerId: string, rawProvider: string) =>
|
||||
getProviderPrefixesFromMaps(aliasMaps, providerId, rawProvider);
|
||||
|
||||
const getComboTargetModelId = (target: ComboCatalogTarget) =>
|
||||
getComboTargetModelIdFromMaps(aliasMaps, target);
|
||||
const getComboTargetModelId = (target: ComboCatalogTarget) => {
|
||||
const resolved = getComboTargetModelIdFromMaps(aliasMaps, target);
|
||||
if (!resolved) return null;
|
||||
const nodeId = providerNodeIdByPrefix[resolved.providerId];
|
||||
return nodeId ? { ...resolved, providerId: nodeId } : resolved;
|
||||
};
|
||||
|
||||
const resolvedComboTargets = combos.flatMap(
|
||||
(combo) =>
|
||||
resolveNestedComboTargets(
|
||||
combo as Parameters<typeof resolveNestedComboTargets>[0],
|
||||
combos as Parameters<typeof resolveNestedComboTargets>[1]
|
||||
) as ComboCatalogTarget[]
|
||||
);
|
||||
const comboProviderIds = new Set(
|
||||
resolvedComboTargets.flatMap((target) => {
|
||||
const resolved = getComboTargetModelId(target);
|
||||
return resolved ? [resolved.providerId] : [];
|
||||
})
|
||||
);
|
||||
const comboSyncedModelsByProvider = new Map<string, ConnectionScopedReasoningCatalog | null>();
|
||||
await Promise.all(
|
||||
[...comboProviderIds].map(async (providerId) => {
|
||||
try {
|
||||
const byConnection = await getSyncedAvailableModelsByConnection(providerId);
|
||||
comboSyncedModelsByProvider.set(
|
||||
providerId,
|
||||
byConnection[SYNCED_AVAILABLE_MODELS_MALFORMED] ? null : byConnection
|
||||
);
|
||||
} catch {
|
||||
// Unknown connection-scoped capability evidence must never broaden a combo.
|
||||
comboSyncedModelsByProvider.set(providerId, null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const getComboTargetCatalogMetadata = (
|
||||
target: ComboCatalogTarget
|
||||
@@ -462,17 +511,61 @@ async function buildUnifiedModelsResponseCore(
|
||||
const targetModel = getComboTargetModelId(target);
|
||||
if (!targetModel) return null;
|
||||
|
||||
const canonical = getCanonicalModelMetadata({
|
||||
provider: targetModel.providerId,
|
||||
model: targetModel.modelId,
|
||||
});
|
||||
const canonical = getCanonicalModelMetadata(
|
||||
{
|
||||
provider: targetModel.providerId,
|
||||
model: targetModel.modelId,
|
||||
},
|
||||
capabilityResolutionSnapshot
|
||||
);
|
||||
if (!canonical) return null;
|
||||
|
||||
const source = canonical.metadata.source;
|
||||
if (!source.providerRegistry && !source.staticSpec && !source.syncedCapability) return null;
|
||||
|
||||
const providerId = canonical.provider || targetModel.providerId;
|
||||
const modelId = canonical.model || targetModel.modelId;
|
||||
const providerAlias = providerIdToAlias[providerId] || PROVIDER_ID_TO_ALIAS[providerId];
|
||||
const allProviderConnections = getConnectionsForProvider(
|
||||
providerId,
|
||||
providerAlias,
|
||||
targetModel.providerId
|
||||
);
|
||||
const providerConnections = allProviderConnections.filter((connection) =>
|
||||
hasEligibleConnectionForModel([connection], modelId)
|
||||
);
|
||||
const hasExplicitConnectionScope =
|
||||
Boolean(target.connectionId) || Boolean(target.allowedConnectionIds?.length);
|
||||
const eligibleConnectionIds =
|
||||
allProviderConnections.length > 0 || hasExplicitConnectionScope
|
||||
? providerConnections.map((connection) => connection.id)
|
||||
: undefined;
|
||||
const source = canonical.metadata.source;
|
||||
const connectionCatalog = comboSyncedModelsByProvider.get(providerId);
|
||||
// A `reasoning_efforts` model-capability override is operator-declared,
|
||||
// provider-scoped authoritative evidence — it must win over (and never be
|
||||
// silently dropped by) the per-connection synced-catalog fail-closed scan
|
||||
// below, or the override would apply in the direct catalog but vanish from
|
||||
// combo `effort_tiers`.
|
||||
const connectionEfforts = source.reasoningEffortsOverride
|
||||
? canonical.capabilities.supportedThinkingEfforts
|
||||
? [...canonical.capabilities.supportedThinkingEfforts]
|
||||
: []
|
||||
: connectionCatalog === null
|
||||
? []
|
||||
: getConnectionScopedEffortTiers(
|
||||
modelId,
|
||||
target,
|
||||
eligibleConnectionIds,
|
||||
connectionCatalog || {}
|
||||
);
|
||||
if (
|
||||
connectionEfforts === undefined &&
|
||||
!source.providerRegistry &&
|
||||
!source.staticSpec &&
|
||||
!source.syncedCapability &&
|
||||
!source.reasoningEffortsOverride
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const synced = getSyncedCapability(providerId, modelId);
|
||||
const spec = getModelSpec(modelId);
|
||||
const registryModel = getRegistryModel(providerId, modelId);
|
||||
@@ -540,12 +633,21 @@ async function buildUnifiedModelsResponseCore(
|
||||
}
|
||||
Object.assign(
|
||||
capabilities,
|
||||
getThinkingCapabilityFields(
|
||||
providerId,
|
||||
modelId,
|
||||
canonical.capabilities.supportsThinking,
|
||||
registryModel?.supportedThinkingEfforts
|
||||
)
|
||||
connectionEfforts === undefined
|
||||
? getThinkingCapabilityFields(
|
||||
providerId,
|
||||
modelId,
|
||||
canonical.capabilities.supportsThinking,
|
||||
registryModel?.supportedThinkingEfforts,
|
||||
true
|
||||
)
|
||||
: getThinkingCapabilityFields(
|
||||
providerId,
|
||||
modelId,
|
||||
connectionEfforts.length > 0 ? true : canonical.capabilities.supportsThinking,
|
||||
connectionEfforts,
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -598,6 +700,9 @@ async function buildUnifiedModelsResponseCore(
|
||||
: [];
|
||||
|
||||
const capabilities = mergeComboCapabilities(knownMetadata);
|
||||
if (targetMetadata.some((metadata) => metadata === null)) {
|
||||
delete capabilities.effort_tiers;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseMetadata,
|
||||
@@ -1745,9 +1850,8 @@ async function buildUnifiedModelsResponseCore(
|
||||
}
|
||||
enrichmentSnapshot = {
|
||||
modelsDevPricing,
|
||||
providerNodeIdsByPrefix: Object.fromEntries(
|
||||
Object.entries(providerIdToPrefix).map(([providerId, prefix]) => [prefix, providerId])
|
||||
),
|
||||
capabilityResolution: capabilityResolutionSnapshot,
|
||||
providerNodeIdsByPrefix: providerNodeIdByPrefix,
|
||||
};
|
||||
// The production profile identified pricing snapshot construction as the last
|
||||
// dominant synchronous stage. Let already-queued health checks run before the
|
||||
|
||||
@@ -30,8 +30,20 @@ export type ComboCatalogTarget = {
|
||||
modelStr?: string;
|
||||
provider?: string | null;
|
||||
providerId?: string | null;
|
||||
connectionId?: string | null;
|
||||
allowedConnectionIds?: string[] | null;
|
||||
};
|
||||
|
||||
type ConnectionScopedReasoningModel = {
|
||||
id: string;
|
||||
supportedThinkingEfforts?: string[];
|
||||
};
|
||||
|
||||
export type ConnectionScopedReasoningCatalog = Record<
|
||||
string,
|
||||
readonly ConnectionScopedReasoningModel[]
|
||||
>;
|
||||
|
||||
export type ComboTargetCatalogMetadata = {
|
||||
contextLength?: number;
|
||||
maxInputTokens?: number;
|
||||
@@ -83,6 +95,54 @@ export function minKnownNumber(values: Array<number | undefined>): number | unde
|
||||
return Math.min(...knownValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the adjustable reasoning efforts shared by every connection a combo target can select.
|
||||
* `undefined` means there is no connection-scoped evidence, so authoritative static metadata may
|
||||
* still apply. An empty array means at least one selectable connection advertised this model but
|
||||
* the complete selectable set did not prove any common adjustable tier, so callers must fail
|
||||
* closed instead of falling back to broader model-family metadata.
|
||||
*/
|
||||
export function getConnectionScopedEffortTiers(
|
||||
modelId: string,
|
||||
target: Pick<ComboCatalogTarget, "connectionId" | "allowedConnectionIds">,
|
||||
eligibleConnectionIds: readonly string[] | undefined,
|
||||
modelsByConnection: ConnectionScopedReasoningCatalog
|
||||
): string[] | undefined {
|
||||
const eligible = eligibleConnectionIds ? new Set(eligibleConnectionIds) : undefined;
|
||||
if (target.connectionId && eligible && !eligible.has(target.connectionId)) return [];
|
||||
if (
|
||||
target.allowedConnectionIds?.length &&
|
||||
eligible &&
|
||||
!target.allowedConnectionIds.some((id) => eligible.has(id))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (!target.connectionId && !target.allowedConnectionIds?.length && eligible?.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const catalogConnectionIds = Object.keys(modelsByConnection);
|
||||
if (catalogConnectionIds.length === 0) return undefined;
|
||||
|
||||
let connectionIds: string[];
|
||||
if (target.connectionId) {
|
||||
connectionIds = !eligible || eligible.has(target.connectionId) ? [target.connectionId] : [];
|
||||
} else if (target.allowedConnectionIds?.length) {
|
||||
connectionIds = target.allowedConnectionIds.filter((id) => !eligible || eligible.has(id));
|
||||
} else {
|
||||
connectionIds = eligible ? [...eligible] : Object.keys(modelsByConnection);
|
||||
}
|
||||
if (connectionIds.length === 0) return [];
|
||||
|
||||
const matching = connectionIds.map((connectionId) =>
|
||||
(modelsByConnection[connectionId] || []).find((model) => model.id === modelId)
|
||||
);
|
||||
if (matching.some((model) => model === undefined)) return [];
|
||||
|
||||
const efforts = matching.map((model) => model?.supportedThinkingEfforts || []);
|
||||
return intersectStringArrays(efforts);
|
||||
}
|
||||
|
||||
export function getThinkingCapabilityFields(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
|
||||
@@ -7194,6 +7194,7 @@
|
||||
"configured": "configured",
|
||||
"none": "None",
|
||||
"modelOverrideValuePlaceholder": "Numeric value",
|
||||
"modelOverrideReasoningEffortsPlaceholder": "English comma-separated, e.g. low, medium, high",
|
||||
"addKeyValue": "Add key value",
|
||||
"noModelOverrides": "No overrides configured for this model.",
|
||||
"modelOverrideLoadFailed": "Failed to load model overrides",
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import {
|
||||
parseReasoningEffortsOverride,
|
||||
REASONING_EFFORT_OVERRIDE_VALUES,
|
||||
type ReasoningEffortOverrideValue,
|
||||
} from "@/shared/reasoning/reasoningEffortsOverride";
|
||||
import { getDbInstance } from "./core";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
|
||||
export type ModelCapabilityOverrideKey = "max_input_tokens" | "max_output_tokens" | "max_token";
|
||||
export type NumericModelCapabilityOverrideKey =
|
||||
| "max_input_tokens"
|
||||
| "max_output_tokens"
|
||||
| "max_token";
|
||||
export type ModelCapabilityOverrideKey = NumericModelCapabilityOverrideKey | "reasoning_efforts";
|
||||
|
||||
export interface ModelCapabilityOverride {
|
||||
interface ModelCapabilityOverrideBase {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
target: string;
|
||||
key: ModelCapabilityOverrideKey;
|
||||
value: number;
|
||||
refreshedAt: string;
|
||||
}
|
||||
|
||||
export type ModelCapabilityOverride =
|
||||
| (ModelCapabilityOverrideBase & {
|
||||
key: NumericModelCapabilityOverrideKey;
|
||||
value: number;
|
||||
})
|
||||
| (ModelCapabilityOverrideBase & {
|
||||
key: "reasoning_efforts";
|
||||
value: ReasoningEffortOverrideValue[];
|
||||
});
|
||||
|
||||
interface OverrideRow {
|
||||
provider: string;
|
||||
model_id: string;
|
||||
@@ -20,14 +37,27 @@ interface OverrideRow {
|
||||
refreshed_at: string;
|
||||
}
|
||||
|
||||
function isSupportedKey(value: unknown): value is ModelCapabilityOverrideKey {
|
||||
function isNumericKey(value: unknown): value is NumericModelCapabilityOverrideKey {
|
||||
return value === "max_input_tokens" || value === "max_output_tokens" || value === "max_token";
|
||||
}
|
||||
|
||||
function isSupportedKey(value: unknown): value is ModelCapabilityOverrideKey {
|
||||
return isNumericKey(value) || value === "reasoning_efforts";
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function isReasoningEfforts(value: unknown): value is ReasoningEffortOverrideValue[] {
|
||||
if (!Array.isArray(value) || value.length === 0) return false;
|
||||
const allowed = new Set<string>(REASONING_EFFORT_OVERRIDE_VALUES);
|
||||
return (
|
||||
value.every((entry) => typeof entry === "string" && allowed.has(entry)) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
|
||||
export function parseModelOverrideTarget(
|
||||
target: unknown
|
||||
): { provider: string; modelId: string } | null {
|
||||
@@ -51,29 +81,33 @@ function toOverride(row: OverrideRow): ModelCapabilityOverride | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPositiveInteger(parsedValue)) return null;
|
||||
|
||||
return {
|
||||
const base: ModelCapabilityOverrideBase = {
|
||||
provider: row.provider,
|
||||
modelId: row.model_id,
|
||||
target: `${row.provider}/${row.model_id}`,
|
||||
key: row.override_key,
|
||||
value: parsedValue,
|
||||
refreshedAt: row.refreshed_at,
|
||||
};
|
||||
if (row.override_key === "reasoning_efforts") {
|
||||
return isReasoningEfforts(parsedValue)
|
||||
? { ...base, key: row.override_key, value: parsedValue }
|
||||
: null;
|
||||
}
|
||||
return isPositiveInteger(parsedValue)
|
||||
? { ...base, key: row.override_key, value: parsedValue }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Nested provider → model → max_token map used by build-local snapshots. */
|
||||
/** Nested provider → model → numeric override map used by build-local snapshots. */
|
||||
export type NestedMaxTokenOverrideMap = ReadonlyMap<string, ReadonlyMap<string, number>>;
|
||||
|
||||
export function getModelCapabilityOverride(
|
||||
provider: string | null | undefined,
|
||||
modelId: string | null | undefined,
|
||||
key: ModelCapabilityOverrideKey,
|
||||
key: NumericModelCapabilityOverrideKey,
|
||||
bulkMaxTokenOverrides?: NestedMaxTokenOverrideMap | null
|
||||
): number | null {
|
||||
const target = parseModelOverrideTarget(`${provider || ""}/${modelId || ""}`);
|
||||
if (!target || !isSupportedKey(key)) return null;
|
||||
if (!target || !isNumericKey(key)) return null;
|
||||
|
||||
if (bulkMaxTokenOverrides) {
|
||||
// The caller pairs the bulk map with the key it was built for
|
||||
@@ -89,7 +123,30 @@ export function getModelCapabilityOverride(
|
||||
)
|
||||
.get(target.provider, target.modelId, key) as OverrideRow | undefined;
|
||||
const override = row ? toOverride(row) : null;
|
||||
return override?.value ?? null;
|
||||
return override && override.key !== "reasoning_efforts" ? override.value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getReasoningEffortsOverride(
|
||||
provider: string | null | undefined,
|
||||
modelId: string | null | undefined,
|
||||
bulk?: ReadonlyMap<string, ReadonlyMap<string, readonly ReasoningEffortOverrideValue[]>> | null
|
||||
): readonly ReasoningEffortOverrideValue[] | null {
|
||||
const target = parseModelOverrideTarget(`${provider || ""}/${modelId || ""}`);
|
||||
if (!target) return null;
|
||||
if (bulk) return bulk.get(target.provider)?.get(target.modelId) ?? null;
|
||||
|
||||
try {
|
||||
const row = getDbInstance()
|
||||
.prepare(
|
||||
"SELECT provider, model_id, override_key, override_value, refreshed_at " +
|
||||
"FROM model_capability_overrides WHERE provider = ? AND model_id = ? AND override_key = 'reasoning_efforts'"
|
||||
)
|
||||
.get(target.provider, target.modelId) as OverrideRow | undefined;
|
||||
const override = row ? toOverride(row) : null;
|
||||
return override?.key === "reasoning_efforts" ? override.value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -98,10 +155,22 @@ export function getModelCapabilityOverride(
|
||||
export function setModelCapabilityOverride(
|
||||
target: string,
|
||||
key: ModelCapabilityOverrideKey,
|
||||
value: number
|
||||
value: number | string | readonly string[]
|
||||
): boolean {
|
||||
const parsedTarget = parseModelOverrideTarget(target);
|
||||
if (!parsedTarget || !isSupportedKey(key) || !isPositiveInteger(value)) return false;
|
||||
if (!parsedTarget || !isSupportedKey(key)) return false;
|
||||
|
||||
let normalizedValue: number | ReasoningEffortOverrideValue[];
|
||||
if (key === "reasoning_efforts") {
|
||||
const parsed = Array.isArray(value)
|
||||
? parseReasoningEffortsOverride(value.join(","))
|
||||
: parseReasoningEffortsOverride(value);
|
||||
if (!parsed.ok) return false;
|
||||
normalizedValue = parsed.efforts;
|
||||
} else {
|
||||
if (!isPositiveInteger(value)) return false;
|
||||
normalizedValue = value;
|
||||
}
|
||||
|
||||
getDbInstance()
|
||||
.prepare(
|
||||
@@ -109,7 +178,7 @@ export function setModelCapabilityOverride(
|
||||
"(provider, model_id, override_key, override_value, refreshed_at) " +
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))"
|
||||
)
|
||||
.run(parsedTarget.provider, parsedTarget.modelId, key, JSON.stringify(value));
|
||||
.run(parsedTarget.provider, parsedTarget.modelId, key, JSON.stringify(normalizedValue));
|
||||
invalidateDbCache("model-capabilities");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -485,12 +485,19 @@ export async function getSyncedAvailableModels(
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export const SYNCED_AVAILABLE_MODELS_MALFORMED = Symbol("syncedAvailableModelsMalformed");
|
||||
export type SyncedAvailableModelsByConnection = Record<string, SyncedAvailableModel[]> & {
|
||||
[SYNCED_AVAILABLE_MODELS_MALFORMED]?: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get synced available models for a provider grouped by connection id.
|
||||
* A non-enumerable symbol marks malformed persisted rows so strict callers can
|
||||
* fail closed without changing the existing Record-shaped API.
|
||||
*/
|
||||
export async function getSyncedAvailableModelsByConnection(
|
||||
providerId: string
|
||||
): Promise<Record<string, SyncedAvailableModel[]>> {
|
||||
): Promise<SyncedAvailableModelsByConnection> {
|
||||
const db = getDbInstance();
|
||||
const prefix = `${providerId}:`;
|
||||
const rows = db
|
||||
@@ -498,7 +505,7 @@ export async function getSyncedAvailableModelsByConnection(
|
||||
"SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?"
|
||||
)
|
||||
.all(`${prefix}%`);
|
||||
const result: Record<string, SyncedAvailableModel[]> = {};
|
||||
const result: SyncedAvailableModelsByConnection = {};
|
||||
for (const row of rows) {
|
||||
const { key, value } = getKeyValue(row);
|
||||
if (!key || value === null || !key.startsWith(prefix)) continue;
|
||||
@@ -506,7 +513,10 @@ export async function getSyncedAvailableModelsByConnection(
|
||||
const connectionId = key.slice(prefix.length);
|
||||
result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value), providerId);
|
||||
} catch {
|
||||
// Ignore malformed legacy entries.
|
||||
Object.defineProperty(result, SYNCED_AVAILABLE_MODELS_MALFORMED, {
|
||||
value: true,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
import { getSyncedCapability } from "@/lib/modelsDevSync";
|
||||
import { MODELS_DEV_PROVIDER_MAP } from "@/lib/modelsDevSync/transform";
|
||||
import { getModelContextOverride } from "@/lib/db/modelContextOverrides";
|
||||
import { getModelCapabilityOverride } from "@/lib/db/modelCapabilityOverrides";
|
||||
import {
|
||||
getModelCapabilityOverride,
|
||||
getReasoningEffortsOverride,
|
||||
} from "@/lib/db/modelCapabilityOverrides";
|
||||
import { getCustomModelVisionOverride } from "@/lib/db/models";
|
||||
import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot";
|
||||
import { resolveAudioCapability, resolveVideoCapability } from "@/lib/modelCapabilityModalities";
|
||||
@@ -117,6 +120,8 @@ export interface ResolvedModelCapabilities {
|
||||
toolCalling: boolean;
|
||||
reasoning: boolean;
|
||||
supportsThinking: boolean | null;
|
||||
supportedThinkingEfforts: readonly string[] | null;
|
||||
reasoningEffortsOverride: boolean;
|
||||
supportsTools: boolean | null;
|
||||
supportsVision: boolean | null;
|
||||
supportsAudio: boolean | null;
|
||||
@@ -634,6 +639,25 @@ function getMaxInputTokenCapabilityOverride(
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve an exact reasoning-effort vocabulary from the build-local snapshot
|
||||
* when present, otherwise from the on-demand persisted override lookup. */
|
||||
function getReasoningEffortsCapabilityOverride(
|
||||
resolved: {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
rawModel: string | null;
|
||||
},
|
||||
snapshot?: ModelCapabilityResolutionSnapshot | null
|
||||
): readonly string[] | null {
|
||||
const bulk = snapshot?.reasoningEffortsOverrides ?? null;
|
||||
return (
|
||||
getReasoningEffortsOverride(resolved.provider, resolved.model, bulk) ??
|
||||
(resolved.rawModel && resolved.rawModel !== resolved.model
|
||||
? getReasoningEffortsOverride(resolved.provider, resolved.rawModel, bulk)
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
export function getExplicitModelOutputCap(
|
||||
input: CapabilityInput,
|
||||
snapshot?: ModelCapabilityResolutionSnapshot | null
|
||||
@@ -705,13 +729,18 @@ export function getResolvedModelCapabilities(
|
||||
(typeof spec?.supportsTools === "boolean" ? spec.supportsTools : null) ??
|
||||
(providerDeniesTools ? false : null);
|
||||
|
||||
const supportsThinking = reasoningDenied
|
||||
? false
|
||||
: (synced?.reasoning ??
|
||||
(typeof registryModel?.supportsReasoning === "boolean"
|
||||
? registryModel.supportsReasoning
|
||||
: null) ??
|
||||
(typeof spec?.supportsThinking === "boolean" ? spec.supportsThinking : null));
|
||||
const reasoningEffortsOverride = usePersistedOverrides
|
||||
? getReasoningEffortsCapabilityOverride(resolved, snapshot)
|
||||
: null;
|
||||
const supportsThinking = reasoningEffortsOverride
|
||||
? true
|
||||
: reasoningDenied
|
||||
? false
|
||||
: (synced?.reasoning ??
|
||||
(typeof registryModel?.supportsReasoning === "boolean"
|
||||
? registryModel.supportsReasoning
|
||||
: null) ??
|
||||
(typeof spec?.supportsThinking === "boolean" ? spec.supportsThinking : null));
|
||||
|
||||
const authoritativeContextWindow = getAuthoritativeStaticContextWindow(
|
||||
resolved.provider,
|
||||
@@ -785,6 +814,9 @@ export function getResolvedModelCapabilities(
|
||||
toolCalling: supportsTools ?? heuristicToolCalling(lookupKey),
|
||||
reasoning: supportsThinking ?? heuristicReasoning(lookupKey),
|
||||
supportsThinking,
|
||||
supportedThinkingEfforts:
|
||||
reasoningEffortsOverride ?? registryModel?.supportedThinkingEfforts ?? null,
|
||||
reasoningEffortsOverride: reasoningEffortsOverride !== null,
|
||||
supportsTools,
|
||||
supportsVision,
|
||||
supportsAudio,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* collide via delimiter composition.
|
||||
*/
|
||||
import { listModelCapabilityOverrides } from "@/lib/db/modelCapabilityOverrides";
|
||||
import type { ReasoningEffortOverrideValue } from "@/shared/reasoning/reasoningEffortsOverride";
|
||||
import { listModelContextOverrides } from "@/lib/db/modelContextOverrides";
|
||||
import {
|
||||
listCustomModelVisionOverrides,
|
||||
@@ -22,11 +23,16 @@ import {
|
||||
|
||||
/** Nested provider → model → numeric override map (collision-free). */
|
||||
export type NestedOverrideMap = ReadonlyMap<string, ReadonlyMap<string, number>>;
|
||||
export type NestedReasoningEffortsOverrideMap = ReadonlyMap<
|
||||
string,
|
||||
ReadonlyMap<string, readonly ReasoningEffortOverrideValue[]>
|
||||
>;
|
||||
|
||||
export interface ModelCapabilityResolutionSnapshot {
|
||||
readonly synced: CapabilitiesByProvider;
|
||||
readonly maxTokenOverrides: NestedOverrideMap;
|
||||
readonly maxInputTokenOverrides: NestedOverrideMap;
|
||||
readonly reasoningEffortsOverrides: NestedReasoningEffortsOverrideMap;
|
||||
readonly contextOverrides: NestedOverrideMap;
|
||||
readonly customVisionOverrides: CustomModelVisionOverrideMap;
|
||||
}
|
||||
@@ -61,11 +67,22 @@ export function createModelCapabilityResolutionSnapshot(
|
||||
|
||||
const maxTokenOverrides = new Map<string, Map<string, number>>();
|
||||
const maxInputTokenOverrides = new Map<string, Map<string, number>>();
|
||||
const reasoningEffortsOverrides = new Map<
|
||||
string,
|
||||
Map<string, readonly ReasoningEffortOverrideValue[]>
|
||||
>();
|
||||
for (const entry of listModelCapabilityOverrides()) {
|
||||
if (entry.key === "max_output_tokens") {
|
||||
setNestedOverride(maxTokenOverrides, entry.provider, entry.modelId, entry.value);
|
||||
} else if (entry.key === "max_input_tokens") {
|
||||
setNestedOverride(maxInputTokenOverrides, entry.provider, entry.modelId, entry.value);
|
||||
} else if (entry.key === "reasoning_efforts") {
|
||||
let byModel = reasoningEffortsOverrides.get(entry.provider);
|
||||
if (!byModel) {
|
||||
byModel = new Map();
|
||||
reasoningEffortsOverrides.set(entry.provider, byModel);
|
||||
}
|
||||
byModel.set(entry.modelId, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +95,7 @@ export function createModelCapabilityResolutionSnapshot(
|
||||
synced,
|
||||
maxTokenOverrides,
|
||||
maxInputTokenOverrides,
|
||||
reasoningEffortsOverrides,
|
||||
contextOverrides,
|
||||
customVisionOverrides: listCustomModelVisionOverrides(options.customModelVision),
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isNonChatCatalogSurface,
|
||||
} from "@/lib/modelCapabilities";
|
||||
import { getModelCapabilityOverride } from "@/lib/db/modelCapabilityOverrides";
|
||||
import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot";
|
||||
import {
|
||||
getAuthoritativeContextWindow,
|
||||
getAuthoritativeProviderContextWindow,
|
||||
@@ -40,6 +41,7 @@ type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface CatalogEnrichmentSnapshot {
|
||||
modelsDevPricing: PricingByProvider | null;
|
||||
capabilityResolution?: ModelCapabilityResolutionSnapshot;
|
||||
providerNodeIdsByPrefix?: Readonly<Record<string, string>>;
|
||||
/** #9147: build-local bulk load of synced capabilities + token/context overrides
|
||||
* so per-entry enrichment never hits SQLite again (see catalogResponse.ts). */
|
||||
@@ -64,6 +66,7 @@ export interface CanonicalModelMetadata {
|
||||
toolCalling: boolean;
|
||||
reasoning: boolean;
|
||||
supportsThinking: boolean | null;
|
||||
supportedThinkingEfforts: readonly string[] | null;
|
||||
supportsTools: boolean | null;
|
||||
vision: boolean | null;
|
||||
attachment: boolean | null;
|
||||
@@ -90,6 +93,7 @@ export interface CanonicalModelMetadata {
|
||||
providerRegistry: boolean;
|
||||
staticSpec: boolean;
|
||||
syncedCapability: boolean;
|
||||
reasoningEffortsOverride: boolean;
|
||||
};
|
||||
};
|
||||
modalities: {
|
||||
@@ -249,6 +253,7 @@ export function getCanonicalModelMetadata(input: {
|
||||
toolCalling: resolved.toolCalling,
|
||||
reasoning: resolved.reasoning,
|
||||
supportsThinking: resolved.supportsThinking,
|
||||
supportedThinkingEfforts: resolved.supportedThinkingEfforts,
|
||||
supportsTools: resolved.supportsTools,
|
||||
vision: resolved.supportsVision,
|
||||
attachment: resolved.attachment,
|
||||
@@ -275,6 +280,7 @@ export function getCanonicalModelMetadata(input: {
|
||||
providerRegistry: Boolean(registryModel),
|
||||
staticSpec: Boolean(staticSpec),
|
||||
syncedCapability: Boolean(syncedCapability),
|
||||
reasoningEffortsOverride: resolved.reasoningEffortsOverride,
|
||||
},
|
||||
},
|
||||
modalities: {
|
||||
@@ -437,10 +443,6 @@ export function enrichCatalogModelEntry<T extends JsonRecord>(
|
||||
snapshot: snapshot?.capabilityResolutionSnapshot ?? null,
|
||||
});
|
||||
if (!metadata) return entry;
|
||||
const registryModel = getRegistryModel(
|
||||
metadata.providerAlias || metadata.provider,
|
||||
metadata.model
|
||||
);
|
||||
|
||||
const nextEntry: JsonRecord = { ...entry };
|
||||
const existingName = asNonEmptyString(entry.name);
|
||||
@@ -484,9 +486,9 @@ export function enrichCatalogModelEntry<T extends JsonRecord>(
|
||||
...(metadata.capabilities.supportsThinking
|
||||
? {
|
||||
effort_tiers:
|
||||
registryModel?.supportedThinkingEfforts &&
|
||||
registryModel.supportedThinkingEfforts.length > 0
|
||||
? [...registryModel.supportedThinkingEfforts]
|
||||
metadata.capabilities.supportedThinkingEfforts &&
|
||||
metadata.capabilities.supportedThinkingEfforts.length > 0
|
||||
? [...metadata.capabilities.supportedThinkingEfforts]
|
||||
: extendCodexGpt56EffortValues(
|
||||
metadata.provider,
|
||||
metadata.model,
|
||||
|
||||
55
src/shared/reasoning/reasoningEffortsOverride.ts
Normal file
55
src/shared/reasoning/reasoningEffortsOverride.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const REASONING_EFFORT_OVERRIDE_VALUES = [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
] as const;
|
||||
|
||||
export type ReasoningEffortOverrideValue = (typeof REASONING_EFFORT_OVERRIDE_VALUES)[number];
|
||||
|
||||
const REASONING_EFFORT_OVERRIDE_SET = new Set<string>(REASONING_EFFORT_OVERRIDE_VALUES);
|
||||
const EDGE_INVISIBLE_PATTERN =
|
||||
/^[\p{White_Space}\p{Separator}\p{Control}\p{Format}]+|[\p{White_Space}\p{Separator}\p{Control}\p{Format}]+$/gu;
|
||||
const NON_ASCII_COMMA_PATTERN =
|
||||
/[،、︐︑﹐﹑,、]/u;
|
||||
|
||||
export type ReasoningEffortsOverrideParseResult =
|
||||
{ ok: true; efforts: ReasoningEffortOverrideValue[] } | { ok: false; error: string };
|
||||
|
||||
function stripInvisibleEdges(value: string): string {
|
||||
return value.replace(EDGE_INVISIBLE_PATTERN, "");
|
||||
}
|
||||
|
||||
/** Parse one ASCII-comma-separated native reasoning-effort vocabulary. */
|
||||
export function parseReasoningEffortsOverride(value: unknown): ReasoningEffortsOverrideParseResult {
|
||||
if (typeof value !== "string") {
|
||||
return { ok: false, error: "reasoning_efforts must be a string" };
|
||||
}
|
||||
if (NON_ASCII_COMMA_PATTERN.test(value)) {
|
||||
return { ok: false, error: "reasoning_efforts must use English commas" };
|
||||
}
|
||||
|
||||
const segments = value.split(",");
|
||||
|
||||
const efforts: ReasoningEffortOverrideValue[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const segment of segments) {
|
||||
const effort = stripInvisibleEdges(segment).toLowerCase();
|
||||
if (!effort) {
|
||||
return { ok: false, error: "reasoning_efforts contains an empty item" };
|
||||
}
|
||||
if (!REASONING_EFFORT_OVERRIDE_SET.has(effort)) {
|
||||
return { ok: false, error: `Unsupported reasoning effort: ${effort}` };
|
||||
}
|
||||
if (seen.has(effort)) {
|
||||
return { ok: false, error: `Duplicate reasoning effort: ${effort}` };
|
||||
}
|
||||
seen.add(effort);
|
||||
efforts.push(effort as ReasoningEffortOverrideValue);
|
||||
}
|
||||
|
||||
return { ok: true, efforts };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
minKnownNumber,
|
||||
maybeOmitCatalogModelName,
|
||||
getThinkingCapabilityFields,
|
||||
getConnectionScopedEffortTiers,
|
||||
} from "../../src/app/api/v1/models/catalogHelpers.ts";
|
||||
import {
|
||||
qualifyOpenRouterModelId,
|
||||
@@ -84,6 +85,73 @@ test("catalogHelpers: Kiro GPT-5.6 models expose the native Max tier", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("catalogHelpers: connection-scoped combo efforts honor dynamic, pinned, and allowlisted scopes", () => {
|
||||
const modelsByConnection = {
|
||||
first: [{ id: "grok-4.6", supportedThinkingEfforts: ["low", "medium", "high"] }],
|
||||
second: [{ id: "grok-4.6", supportedThinkingEfforts: ["medium", "high"] }],
|
||||
unknown: [{ id: "other-model", supportedThinkingEfforts: ["low"] }],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", {}, ["first", "second"], modelsByConnection),
|
||||
["medium", "high"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers(
|
||||
"grok-4.6",
|
||||
{ connectionId: "first" },
|
||||
["first", "second"],
|
||||
modelsByConnection
|
||||
),
|
||||
["low", "medium", "high"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers(
|
||||
"grok-4.6",
|
||||
{ allowedConnectionIds: ["second"] },
|
||||
["first", "second"],
|
||||
modelsByConnection
|
||||
),
|
||||
["medium", "high"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", {}, undefined, modelsByConnection),
|
||||
[],
|
||||
"a dynamic target fails closed when any catalog-backed connection lacks the model"
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", {}, ["first", "unknown"], modelsByConnection),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", {}, ["first", "no-tiers"], {
|
||||
...modelsByConnection,
|
||||
"no-tiers": [{ id: "grok-4.6" }],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", {}, ["unknown"], modelsByConnection),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("missing-model", {}, undefined, {
|
||||
nodeCatalog: [{ id: "other-model" }],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
assert.equal(getConnectionScopedEffortTiers("grok-4.6", {}, ["first"], {}), undefined);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", { connectionId: "stale" }, ["first"], {}),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
getConnectionScopedEffortTiers("grok-4.6", { allowedConnectionIds: ["stale"] }, ["first"], {}),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(getConnectionScopedEffortTiers("grok-4.6", {}, [], {}), []);
|
||||
});
|
||||
|
||||
test("catalogHelpers: minKnownNumber ignores non-positive/unknown", () => {
|
||||
assert.equal(minKnownNumber([3, 1, 2]), 1);
|
||||
assert.equal(minKnownNumber([undefined, 0, -5, 7]), 7);
|
||||
|
||||
@@ -177,6 +177,7 @@ test("grok-cli live model discovery uses the authenticated session contract", ()
|
||||
owned_by: "grok-cli",
|
||||
inputTokenLimit: 500000,
|
||||
supportsThinking: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
apiFormat: "responses",
|
||||
supportedEndpoints: ["responses"],
|
||||
},
|
||||
|
||||
@@ -193,6 +193,60 @@ describe("model capability overrides", () => {
|
||||
assert.equal(rejectedDelete.status, 400);
|
||||
});
|
||||
|
||||
it("stores exact reasoning_efforts through the API and preserves native max/ultra", async () => {
|
||||
const before = caps.getResolvedModelCapabilities("codex/gpt-5.6");
|
||||
const accepted = await patchOverride(
|
||||
"reasoning_efforts",
|
||||
" low\r\n, medium, max, ultra"
|
||||
);
|
||||
assert.equal(accepted.status, 200);
|
||||
|
||||
const payload = (await accepted.json()) as {
|
||||
overrides: Array<{ target: string; key: string; value: number | string[] }>;
|
||||
};
|
||||
const listed = payload.overrides.find((override) => override.key === "reasoning_efforts");
|
||||
assert.ok(listed);
|
||||
assert.equal(listed.target, "codex/gpt-5.6");
|
||||
assert.deepEqual(listed.value, ["low", "medium", "max", "ultra"]);
|
||||
assert.deepEqual(overrides.getReasoningEffortsOverride("codex", "gpt-5.6"), [
|
||||
"low",
|
||||
"medium",
|
||||
"max",
|
||||
"ultra",
|
||||
]);
|
||||
|
||||
const resolved = caps.getResolvedModelCapabilities("codex/gpt-5.6");
|
||||
assert.equal(resolved.supportsThinking, true);
|
||||
assert.equal(resolved.reasoningEffortsOverride, true);
|
||||
assert.deepEqual(resolved.supportedThinkingEfforts, ["low", "medium", "max", "ultra"]);
|
||||
|
||||
for (const invalid of [
|
||||
"",
|
||||
"low,",
|
||||
"low,,high",
|
||||
"low, LOW",
|
||||
"minimal,low",
|
||||
"low,unknown",
|
||||
"low,high",
|
||||
]) {
|
||||
assert.equal((await patchOverride("reasoning_efforts", invalid)).status, 400, invalid);
|
||||
}
|
||||
assert.equal((await patchOverride("reasoning_efforts", ["low", "high"])).status, 400);
|
||||
assert.equal((await patchOverride("reasoning_efforts", 123)).status, 400);
|
||||
|
||||
const removed = await route.DELETE(
|
||||
new Request(
|
||||
"http://localhost/api/model-capability-overrides?target=codex/gpt-5.6&key=reasoning_efforts",
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(removed.status, 200);
|
||||
assert.equal(overrides.getReasoningEffortsOverride("codex", "gpt-5.6"), null);
|
||||
const after = caps.getResolvedModelCapabilities("codex/gpt-5.6");
|
||||
assert.equal(after.reasoningEffortsOverride, false);
|
||||
assert.deepEqual(after.supportedThinkingEfforts, before.supportedThinkingEfforts);
|
||||
});
|
||||
|
||||
it("rejects invalid targets and non-positive values", () => {
|
||||
assert.equal(overrides.setModelCapabilityOverride("gpt-4o", "max_output_tokens", 1000), false);
|
||||
assert.equal(
|
||||
|
||||
@@ -153,6 +153,22 @@ test("#9199 capability data writes advance the model-catalog generation", () =>
|
||||
true
|
||||
);
|
||||
});
|
||||
expectGenerationAdvance("set reasoning_efforts override", () => {
|
||||
assert.equal(
|
||||
capabilityOverrides.setModelCapabilityOverride(
|
||||
"openai/gpt-5.4-mini",
|
||||
"reasoning_efforts",
|
||||
"low,max,ultra"
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
expectGenerationAdvance("remove reasoning_efforts override", () => {
|
||||
assert.equal(
|
||||
capabilityOverrides.removeModelCapabilityOverride("openai/gpt-5.4-mini", "reasoning_efforts"),
|
||||
true
|
||||
);
|
||||
});
|
||||
expectGenerationAdvance("setModelContextOverride", () => {
|
||||
assert.equal(contextOverrides.setModelContextOverride("openai", "gpt-5.4-mini", 400000), true);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,10 @@ process.env.API_KEY_SECRET ||= "combo-metadata-test-secret";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts");
|
||||
const capabilityOverrides = await import("../../src/lib/db/modelCapabilityOverrides.ts");
|
||||
const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts");
|
||||
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
test.after(() => {
|
||||
@@ -51,10 +54,17 @@ test("single-target combo preserves its direct model metadata", async () => {
|
||||
"max_output_tokens",
|
||||
"input_modalities",
|
||||
"output_modalities",
|
||||
"capabilities",
|
||||
]) {
|
||||
assert.deepEqual(combo[field], direct[field], field);
|
||||
}
|
||||
const comboCapabilities = combo.capabilities as Record<string, unknown>;
|
||||
assert.equal(comboCapabilities.reasoning, true);
|
||||
assert.equal(comboCapabilities.supportsThinking, true);
|
||||
assert.equal(
|
||||
Object.hasOwn(comboCapabilities, "effort_tiers"),
|
||||
false,
|
||||
"the combo must not infer adjustable tiers from the Codex model id"
|
||||
);
|
||||
});
|
||||
|
||||
test("single-target Codex combo advertises a larger model context override", async () => {
|
||||
@@ -125,6 +135,136 @@ test("single-target combo respects registry reasoning overrides before specs", a
|
||||
assert.equal(Object.hasOwn(capabilities, "effort_tiers"), false);
|
||||
});
|
||||
|
||||
test("reasoning_efforts overrides project exact native tiers to direct models and combo intersections", async () => {
|
||||
const openaiTarget = "openai/gpt-4o";
|
||||
const anthropicTarget = "anthropic/claude-sonnet-4-5";
|
||||
assert.equal(
|
||||
capabilityOverrides.setModelCapabilityOverride(openaiTarget, "reasoning_efforts", "low,max,ultra"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
capabilityOverrides.setModelCapabilityOverride(
|
||||
anthropicTarget,
|
||||
"reasoning_efforts",
|
||||
"medium,max,ultra"
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
try {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "reasoning-efforts-openai-combo",
|
||||
apiKey: "openai-test-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "anthropic",
|
||||
authType: "apikey",
|
||||
name: "reasoning-efforts-anthropic-combo",
|
||||
apiKey: "anthropic-test-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "reasoning-efforts-override-combo",
|
||||
strategy: "auto",
|
||||
models: [openaiTarget, anthropicTarget],
|
||||
});
|
||||
|
||||
const response = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<Record<string, unknown>> };
|
||||
const direct = body.data.find((item) => item.id === openaiTarget);
|
||||
const combo = body.data.find((item) => item.id === "reasoning-efforts-override-combo");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(direct);
|
||||
assert.ok(combo);
|
||||
assert.deepEqual((direct.capabilities as Record<string, unknown>).effort_tiers, [
|
||||
"low",
|
||||
"max",
|
||||
"ultra",
|
||||
]);
|
||||
assert.deepEqual((combo.capabilities as Record<string, unknown>).effort_tiers, [
|
||||
"max",
|
||||
"ultra",
|
||||
]);
|
||||
} finally {
|
||||
capabilityOverrides.removeModelCapabilityOverride(openaiTarget, "reasoning_efforts");
|
||||
capabilityOverrides.removeModelCapabilityOverride(anthropicTarget, "reasoning_efforts");
|
||||
}
|
||||
});
|
||||
|
||||
test("compatible provider-node override reaches direct and combo metadata through its public prefix", async () => {
|
||||
const nodeId = "openai-compatible-chat-reasoning-override";
|
||||
const prefix = "reasoning-override";
|
||||
const modelId = "native-reasoning-model";
|
||||
await providersDb.createProviderNode({
|
||||
id: nodeId,
|
||||
type: "openai-compatible",
|
||||
prefix,
|
||||
name: "Reasoning Override",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: nodeId,
|
||||
authType: "api_key",
|
||||
name: "reasoning-override-connection",
|
||||
apiKey: "sk-test",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(nodeId, connection.id, [
|
||||
{ id: modelId, name: "Native Reasoning Model" },
|
||||
]);
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${prefix}/${modelId}`,
|
||||
key: "reasoning_efforts",
|
||||
value: "low,max,ultra",
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
assert.deepEqual(capabilityOverrides.getReasoningEffortsOverride(nodeId, modelId), [
|
||||
"low",
|
||||
"max",
|
||||
"ultra",
|
||||
]);
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: "provider-node-reasoning-override-combo",
|
||||
strategy: "auto",
|
||||
models: [`${prefix}/${modelId}`],
|
||||
});
|
||||
const response = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<Record<string, unknown>> };
|
||||
const direct = body.data.find((item) => item.id === `${prefix}/${modelId}`);
|
||||
const combo = body.data.find((item) => item.id === "provider-node-reasoning-override-combo");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(direct);
|
||||
assert.ok(combo);
|
||||
for (const item of [direct, combo]) {
|
||||
assert.deepEqual((item.capabilities as Record<string, unknown>).effort_tiers, [
|
||||
"low",
|
||||
"max",
|
||||
"ultra",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("single-target combo reflects unblocked Antigravity Gemini reasoning", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
@@ -153,7 +293,229 @@ test("single-target combo reflects unblocked Antigravity Gemini reasoning", asyn
|
||||
assert.equal(capabilities.reasoning, true);
|
||||
assert.equal(capabilities.thinking, true);
|
||||
assert.equal(capabilities.supportsThinking, true);
|
||||
assert.equal(Object.hasOwn(capabilities, "effort_tiers"), true);
|
||||
assert.equal(
|
||||
Object.hasOwn(capabilities, "effort_tiers"),
|
||||
false,
|
||||
"reasoning support alone must not synthesize adjustable tiers"
|
||||
);
|
||||
});
|
||||
|
||||
test("malformed connection catalog rows are marked for strict fail-closed consumers", async () => {
|
||||
core
|
||||
.getDbInstance()
|
||||
.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
|
||||
.run("syncedAvailableModels", "malformed-provider:malformed-connection", "{not-json");
|
||||
|
||||
const byConnection = await modelsDb.getSyncedAvailableModelsByConnection("malformed-provider");
|
||||
assert.equal(byConnection[modelsDb.SYNCED_AVAILABLE_MODELS_MALFORMED], true);
|
||||
assert.deepEqual(Object.keys(byConnection), []);
|
||||
});
|
||||
|
||||
test("dynamic-account combo advertises only efforts shared by every selectable connection", async () => {
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: "grok-cli",
|
||||
authType: "oauth",
|
||||
name: "grok-4.6-dynamic-first",
|
||||
accessToken: "grok-first-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: "grok-cli",
|
||||
authType: "oauth",
|
||||
name: "grok-4.6-dynamic-second",
|
||||
accessToken: "grok-second-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("grok-cli", first.id, [
|
||||
{
|
||||
id: "grok-4.6",
|
||||
name: "Grok 4.6",
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
]);
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("grok-cli", second.id, [
|
||||
{
|
||||
id: "grok-4.6",
|
||||
name: "Grok 4.6",
|
||||
supportedThinkingEfforts: ["medium", "high"],
|
||||
},
|
||||
]);
|
||||
await combosDb.createCombo({
|
||||
name: "grok-dynamic-combo",
|
||||
strategy: "auto",
|
||||
models: ["grok-cli/grok-4.6"],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "grok-pinned-combo",
|
||||
strategy: "auto",
|
||||
models: [
|
||||
{
|
||||
kind: "model",
|
||||
model: "grok-cli/grok-4.6",
|
||||
connectionId: first.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "grok-allowlisted-combo",
|
||||
strategy: "auto",
|
||||
models: [
|
||||
{
|
||||
kind: "model",
|
||||
model: "grok-cli/grok-4.6",
|
||||
allowedConnectionIds: [second.id],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<Record<string, unknown>> };
|
||||
const capabilitiesFor = (comboId: string) => {
|
||||
const combo = body.data.find((item) => item.id === comboId);
|
||||
assert.ok(combo, comboId);
|
||||
return combo.capabilities as Record<string, unknown>;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(capabilitiesFor("grok-dynamic-combo").effort_tiers, ["medium", "high"]);
|
||||
assert.deepEqual(capabilitiesFor("grok-pinned-combo").effort_tiers, ["low", "medium", "high"]);
|
||||
assert.deepEqual(capabilitiesFor("grok-allowlisted-combo").effort_tiers, ["medium", "high"]);
|
||||
|
||||
const unknown = await providersDb.createProviderConnection({
|
||||
provider: "grok-cli",
|
||||
authType: "oauth",
|
||||
name: "grok-4.6-unknown-efforts",
|
||||
accessToken: "grok-unknown-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("grok-cli", unknown.id, [
|
||||
{ id: "grok-4.6", name: "Grok 4.6" },
|
||||
]);
|
||||
await combosDb.createCombo({
|
||||
name: "grok-unknown-efforts-combo",
|
||||
strategy: "auto",
|
||||
models: [
|
||||
{
|
||||
kind: "model",
|
||||
model: "grok-cli/grok-4.6",
|
||||
allowedConnectionIds: [first.id, unknown.id],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const failClosedResponse = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const failClosedBody = (await failClosedResponse.json()) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
const failClosedCombo = failClosedBody.data.find(
|
||||
(item) => item.id === "grok-unknown-efforts-combo"
|
||||
);
|
||||
assert.ok(failClosedCombo);
|
||||
assert.equal(
|
||||
Object.hasOwn(failClosedCombo.capabilities as Record<string, unknown>, "effort_tiers"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("provider-node combo intersects connection-scoped efforts behind its public prefix", async () => {
|
||||
const nodeId = "openai-compatible-chat-connection-efforts";
|
||||
const prefix = "scoped-efforts";
|
||||
const modelId = "reasoning-model";
|
||||
await providersDb.createProviderNode({
|
||||
id: nodeId,
|
||||
type: "openai-compatible",
|
||||
prefix,
|
||||
name: "Scoped Efforts",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: nodeId,
|
||||
authType: "api_key",
|
||||
name: "scoped-efforts-first",
|
||||
apiKey: "sk-first",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: nodeId,
|
||||
authType: "api_key",
|
||||
name: "scoped-efforts-second",
|
||||
apiKey: "sk-second",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(nodeId, first.id, [
|
||||
{ id: modelId, supportedThinkingEfforts: ["low", "high"] },
|
||||
]);
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(nodeId, second.id, [
|
||||
{ id: modelId, supportedThinkingEfforts: ["high"] },
|
||||
]);
|
||||
await combosDb.createCombo({
|
||||
name: "provider-node-efforts-combo",
|
||||
strategy: "auto",
|
||||
models: [`${prefix}/${modelId}`],
|
||||
});
|
||||
|
||||
const response = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<Record<string, unknown>> };
|
||||
const combo = body.data.find((item) => item.id === "provider-node-efforts-combo");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(combo);
|
||||
assert.deepEqual((combo.capabilities as Record<string, unknown>).effort_tiers, ["high"]);
|
||||
});
|
||||
|
||||
test("multi-target combo does not ignore a target with unknown reasoning metadata", async () => {
|
||||
await providersDb
|
||||
.createProviderConnection({
|
||||
provider: "grok-cli",
|
||||
authType: "oauth",
|
||||
name: "known-target-mixed-combo",
|
||||
accessToken: "grok-known-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
})
|
||||
.then((connection) =>
|
||||
modelsDb.replaceSyncedAvailableModelsForConnection("grok-cli", connection.id, [
|
||||
{
|
||||
id: "grok-4.6",
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
])
|
||||
);
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "api_key",
|
||||
name: "unknown-target-mixed-combo",
|
||||
apiKey: "ghp-test",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "known-and-unknown-efforts-combo",
|
||||
strategy: "auto",
|
||||
models: ["grok-cli/grok-4.6", "github/catalog-unknown-model"],
|
||||
});
|
||||
|
||||
const response = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<Record<string, unknown>> };
|
||||
const combo = body.data.find((item) => item.id === "known-and-unknown-efforts-combo");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(combo);
|
||||
assert.equal(Object.hasOwn(combo.capabilities as Record<string, unknown>, "effort_tiers"), false);
|
||||
});
|
||||
|
||||
test("mixed DeepSeek combos advertise the efforts accepted by every V4 target", async () => {
|
||||
|
||||
@@ -149,6 +149,35 @@ test("providerModelsConfig aimlapi.parseResponse keeps only chat-completion mode
|
||||
assert.deepEqual(parsed, [{ id: "chat-1", name: "Chat 1" }]);
|
||||
});
|
||||
|
||||
test("providerModelsConfig grok-cli.parseResponse preserves exact supported reasoning efforts", () => {
|
||||
const parsed = PROVIDER_MODELS_CONFIG["grok-cli"].parseResponse({
|
||||
models: [
|
||||
{
|
||||
id: "grok-4.6",
|
||||
api_backend: "responses",
|
||||
supports_reasoning_effort: true,
|
||||
reasoning_efforts: [" high ", "low", "medium", "xhigh", "low"],
|
||||
},
|
||||
{
|
||||
id: "grok-4.7",
|
||||
api_backend: "responses",
|
||||
supports_reasoning_effort: true,
|
||||
},
|
||||
{
|
||||
id: "grok-4.8",
|
||||
api_backend: "responses",
|
||||
supports_reasoning_effort: true,
|
||||
reasoning_efforts: ["xhigh", "unknown"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed[0].supportedThinkingEfforts, ["high", "low", "medium"]);
|
||||
assert.deepEqual(parsed[1].supportedThinkingEfforts, ["low", "medium", "high"]);
|
||||
assert.equal(parsed[2].supportsThinking, true);
|
||||
assert.equal(parsed[2].supportedThinkingEfforts, undefined);
|
||||
});
|
||||
|
||||
test("providerModelsConfig openrouter.parseResponse keeps the full catalog (LLMs not filtered out)", () => {
|
||||
// Generic OpenRouter discovery must stay unfiltered so sync/import/pickers
|
||||
// and /v1/models keep every LLM. STT narrowing lives on the STT card, not here.
|
||||
|
||||
41
tests/unit/reasoning-efforts-override-parser.test.ts
Normal file
41
tests/unit/reasoning-efforts-override-parser.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { parseReasoningEffortsOverride } =
|
||||
await import("../../src/shared/reasoning/reasoningEffortsOverride.ts");
|
||||
|
||||
test("parses native reasoning efforts using only ASCII commas", () => {
|
||||
assert.deepEqual(parseReasoningEffortsOverride("none, low,medium, high, xhigh,max,ultra"), {
|
||||
ok: true,
|
||||
efforts: ["none", "low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
});
|
||||
});
|
||||
|
||||
test("strips whitespace, CR/LF, NBSP, BOM, and zero-width edge characters", () => {
|
||||
assert.deepEqual(
|
||||
parseReasoningEffortsOverride(" low\r\n, max , ultra"),
|
||||
{ ok: true, efforts: ["low", "max", "ultra"] }
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects empty, duplicate, unknown, and non-ASCII-comma input", () => {
|
||||
for (const input of [
|
||||
"",
|
||||
"low,",
|
||||
",low",
|
||||
"low,,high",
|
||||
"low, low",
|
||||
"LOW, low",
|
||||
"minimal,low",
|
||||
"low,unknown",
|
||||
"low,high",
|
||||
"low、high",
|
||||
]) {
|
||||
const parsed = parseReasoningEffortsOverride(input);
|
||||
assert.equal(parsed.ok, false, input);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not strip invisible characters from the middle of an effort", () => {
|
||||
assert.equal(parseReasoningEffortsOverride("low,high").ok, false);
|
||||
});
|
||||
@@ -56,6 +56,82 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ModelCapabilityOverridesTab (issue #9557)", () => {
|
||||
it("uses a text input and sends the raw comma-separated reasoning_efforts string", async () => {
|
||||
const catalog = {
|
||||
codex: {
|
||||
id: "codex",
|
||||
alias: "codex",
|
||||
displayPrefix: "codex",
|
||||
name: "Codex",
|
||||
authType: "oauth",
|
||||
format: "openai",
|
||||
models: [{ id: "gpt-5.6", name: "GPT 5.6" }],
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementation((input: any, init: RequestInit | undefined) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/pricing/models")) return Promise.resolve(jsonResponse(catalog));
|
||||
if (url.includes("/api/model-capability-overrides")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
overrides:
|
||||
init?.method === "PATCH"
|
||||
? [
|
||||
{
|
||||
target: "codex/gpt-5.6",
|
||||
key: "reasoning_efforts",
|
||||
value: ["low", "max", "ultra"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
|
||||
});
|
||||
|
||||
render();
|
||||
await act(async () => flush());
|
||||
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
act(() => {
|
||||
select.value = "reasoning_efforts";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
const valueInput = document.querySelector(
|
||||
'input[placeholder*="English comma-separated"]'
|
||||
) as HTMLInputElement;
|
||||
expect(valueInput).toBeTruthy();
|
||||
expect(valueInput.type).toBe("text");
|
||||
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(valueInput, " low, max, ultra ");
|
||||
valueInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const addButton = Array.from(document.querySelectorAll("button")).find((button) =>
|
||||
(button.textContent ?? "").includes("Add key value")
|
||||
);
|
||||
await act(async () => {
|
||||
addButton!.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const patchCall = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).includes("/api/model-capability-overrides") &&
|
||||
(init as RequestInit | undefined)?.method === "PATCH"
|
||||
);
|
||||
expect(patchCall).toBeTruthy();
|
||||
expect(JSON.parse(String(patchCall![1].body))).toEqual({
|
||||
target: "codex/gpt-5.6",
|
||||
key: "reasoning_efforts",
|
||||
value: " low, max, ultra ",
|
||||
});
|
||||
expect(document.body.textContent).toContain("low, max, ultra");
|
||||
});
|
||||
|
||||
it("renders the public prefix, never the node UUID, and PATCH/DELETE use prefix/model", async () => {
|
||||
const catalog = {
|
||||
[NODE_ID]: {
|
||||
|
||||
Reference in New Issue
Block a user