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:
Xiangzhe
2026-08-20 20:31:12 +08:00
committed by GitHub
parent e9dd87ad77
commit 9fc3b29217
23 changed files with 1159 additions and 81 deletions

View File

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

View File

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

View File

@@ -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") {

View File

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

View File

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

View File

@@ -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"],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 };
}

View File

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

View File

@@ -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"],
},

View File

@@ -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",
"lowhigh",
]) {
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(

View File

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

View File

@@ -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 () => {

View File

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

View 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",
"lowhigh",
"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);
});

View File

@@ -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]: {