diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md b/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md index 1d54b9b448..ae113988fe 100644 --- a/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md +++ b/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md @@ -17,6 +17,15 @@ verbatim below. Amendments made at P of the wp3 cycle: (a) "table-less" must con as the static fallback, so the projection follows the installed bundle; (b) exact known full model ids take precedence over the synthetic grammar (open question 1 → yes). +Amendment (c), audit blocker 1 (005): on `/v1/messages` reuse the existing `effortOverride` +slot (`claude-messages.ts:603/649`, written as `output_config.effort` before translation and +already respected by `anthropicToResponsesTranslation`) instead of injecting the internal +Responses `reasoning.effort`: `effortOverride = effortRow?.effort ?? extractOcxEffortDirective(...)`. +The `none` rung the lane worried about is never published as a row (Cursor's own ladders have +no `none`; filter it from the row set), so the translator's exclusion of `none` is moot. +Amendment (d): `tableLess` in the status route uses `predictCursorEffort(id, table, supportsReasoning).ladder === null` +(wp1 landed the `supportsReasoning` parameter). + --- No files were modified. The untracked `devlog/_plan/260902_cursor_bundle_effort_table/` appeared concurrently and was left untouched. No tests were run. @@ -413,4 +422,3 @@ Do not run the repository-wide suite in this lane. - Should the dashboard merely report `effortRows`, or render them inline under each table-less base? This lane recommends the API contract now and leaves presentation to the UI/UX lane. - Should synthetic rows be added for table-less aliases of otherwise table-matched models? Recommended: yes—the matcher sees the public ID, so an alias such as `opus` genuinely has no Cursor control. - diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 29e0c59053..fb28e8055a 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -48,6 +48,15 @@ Aliases are optional short request names. They never change the native model id Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. Codex model pickers show the qualified alias while preserving the canonical `provider/model` routing id. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern. +### Cursor effort rows + +`cursorEffortRows` is an optional boolean and defaults to `false`. When enabled, the raw OpenAI-style +`/v1/models` list adds `--` selectors for reasoning-capable models that Cursor Private +Inference does not match in its installed effort table. Selecting a generated row routes the base model +and applies that row's effort; models Cursor already recognizes receive no variants. The flag reserves a +terminal `--` suffix for generated selectors, except when the complete value is already +a known configured model id. Cursor may require a model-list refresh or restart after this setting changes. + Valid values in `config.json` override built-in defaults. Missing optional fields use the defaults documented on the domain pages. `OPENCODEX_HOME` takes precedence over the default configuration directory. Fields that accept an environment reference, such as `apiKey: "${PROVIDER_API_KEY}"`, diff --git a/gui/src/pages/integrations/cursor-api.ts b/gui/src/pages/integrations/cursor-api.ts index 18e077f24c..d1eddcf263 100644 --- a/gui/src/pages/integrations/cursor-api.ts +++ b/gui/src/pages/integrations/cursor-api.ts @@ -13,6 +13,8 @@ export interface CursorModelExpectation { id: string; reasoning: string[] | null; family: string | null; + tableLess: boolean; + effortRows: string[]; context: { defaultWindow: number; longWindow: number } | null; } diff --git a/src/config.ts b/src/config.ts index 0641f772eb..2ec7354600 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1046,6 +1046,8 @@ const configSchema = z.object({ providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), + // Malformed hand edits disable this opt-in projection without rejecting providers. + cursorEffortRows: z.boolean().optional().catch(false), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index db084df490..cb3ddbb4d2 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -46,6 +46,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; +import { parseRequestEffortRowId } from "./effort-row"; type Rec = Record; @@ -104,6 +105,8 @@ async function handleChatCompletionsWithBudget( } const requestedModel = chatBody.model as string; + const effortRow = parseRequestEffortRowId(requestedModel, config); + if (effortRow) chatBody.model = effortRow.baseId; const stream = chatBody.stream === true; // Best-effort Grok attribution: the managed fence stamps this header on every model // it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage @@ -113,7 +116,7 @@ async function handleChatCompletionsWithBudget( let settledRoute: ReturnType | null = null; let chatNativeRoute: ReturnType | null = null; try { - const route = routeModel(config, requestedModel, evidenceFromBody(chatBody)); + const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); // Settle the wire once so every branch below reads the adapter this model will // actually use, not the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat"); @@ -133,7 +136,7 @@ async function handleChatCompletionsWithBudget( if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools)); logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); } - if (isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; + if (!effortRow && isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; } catch (err) { if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; @@ -162,6 +165,12 @@ async function handleChatCompletionsWithBudget( // Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as // its wire source; this Responses projection is used only by the fallback path. internalBody = chatCompletionsToResponsesBody(chatBody); + if (effortRow) { + internalBody.reasoning = { + ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), + effort: effortRow.effort, + }; + } } catch (err) { const overflow = isTranslatorBudgetExceededError(err); const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 8425395b18..476ac34bc0 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -50,6 +50,10 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; +import { + parseRequestEffortRowId, + type ParsedEffortRowId, +} from "./effort-row"; type Rec = Record; @@ -600,7 +604,9 @@ async function handleClaudeMessagesWithBudget( let anthropicBody: unknown; let internalBody: Rec; let cacheKeySource: ClaudeCacheKeySource = null; - let effortOverride: ReturnType = null; + let effortOverride: string | null = null; + let effortRow: ParsedEffortRowId | null = null; + let requestedModel = ""; try { anthropicBody = await readAnthropicBody(req, translatorBudget); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -620,6 +626,14 @@ async function handleClaudeMessagesWithBudget( effortOverride = extractOcxEffortDirective(anthropicBody); } } + if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + requestedModel = anthropicBody.model; + effortRow = parseRequestEffortRowId(requestedModel, config); + if (effortRow) { + anthropicBody.model = effortRow.baseId; + effortOverride = effortRow.effort; + } + } // Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so // native, routed, and disabled-alias paths are all observable (devlog 130 B1). captureClaudeInbound( @@ -643,7 +657,7 @@ async function handleClaudeMessagesWithBudget( ); if (claudeConversationId) logCtx.conversationId = claudeConversationId; } - if (isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { + if (!effortRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } if (isRec(anthropicBody) && effortOverride) { @@ -669,7 +683,7 @@ async function handleClaudeMessagesWithBudget( ); } - const requestedModel = (anthropicBody as Rec).model as string; + if (!requestedModel) requestedModel = (anthropicBody as Rec).model as string; const stream = internalBody.stream === true; // Routed adapters only support streamed turns; always stream internally and fold // the translated Anthropic SSE into a message JSON for non-streaming clients. diff --git a/src/server/effort-row.ts b/src/server/effort-row.ts new file mode 100644 index 0000000000..03471059d1 --- /dev/null +++ b/src/server/effort-row.ts @@ -0,0 +1,131 @@ +import { comboModelId, comboPublicModelId } from "../combos/types"; +import { detectCursorInstalls } from "../integrations/cursor-detect"; +import { + loadCursorEffortTable, + type CursorEffortTable, +} from "../integrations/cursor-effort-table"; +import { + canonicalizeReasoningEfforts, + isDeclaredReasoningEffort, +} from "../reasoning-effort"; +import { knownModelIdsForProvider } from "../router"; +import { policyModelId, policyPublicModelId } from "../routing/profile"; +import type { OcxConfig } from "../types"; +import { routedSlug } from "../providers/slug-codec"; +import { predictCursorEffort } from "./models-capabilities"; + +const EFFORT_ROW_SEPARATOR = "--"; + +export interface ParsedEffortRowId { + baseId: string; + effort: string; +} + +export type EffortRowKnownIds = ReadonlySet | ((id: string) => boolean); + +export interface EffortRowOptions { + knownIds?: EffortRowKnownIds; + table?: CursorEffortTable | null; + supportsReasoning?: boolean; +} + +function isKnownId(knownIds: EffortRowKnownIds | undefined, id: string): boolean { + return typeof knownIds === "function" ? knownIds(id) : knownIds?.has(id) === true; +} + +export function effortRowId(baseId: string, effort: string): string { + return `${baseId}${EFFORT_ROW_SEPARATOR}${effort}`; +} + +/** + * Exact configured/public ids that must beat the synthetic terminal-suffix grammar. + * This is request-local because live-model cache contents can change while the server runs. + */ +export function knownEffortRowIds(config: OcxConfig): Set { + const ids = new Set(); + for (const [providerName, provider] of Object.entries(config.providers)) { + const known = knownModelIdsForProvider(providerName, provider, config); + const namespaces = [providerName, provider.alias].filter((value): value is string => ( + typeof value === "string" && value.length > 0 + )); + for (const id of known) { + ids.add(id); + ids.add(routedSlug(providerName, id)); + for (const namespace of namespaces) ids.add(`${namespace}/${id}`); + } + for (const alias of Object.values(provider.modelAliases ?? {})) { + ids.add(alias); + for (const namespace of namespaces) ids.add(`${namespace}/${alias}`); + } + } + for (const [id, combo] of Object.entries(config.combos ?? {})) { + ids.add(comboModelId(id)); + ids.add(comboPublicModelId(id, combo)); + } + for (const [id, profile] of Object.entries(config.routingProfiles ?? {})) { + ids.add(policyModelId(id)); + ids.add(policyPublicModelId(id, profile)); + } + return ids; +} + +/** Resolve the installed Private Inference effort table once for the current request. */ +export function loadDetectedCursorEffortTable(): CursorEffortTable | null { + const privateInference = detectCursorInstalls().find(install => install.build === "private-inference"); + return loadCursorEffortTable(privateInference); +} + +export function parseEffortRowId( + id: string, + config: Pick, + options: EffortRowOptions = {}, +): ParsedEffortRowId | null { + if (config.cursorEffortRows !== true || isKnownId(options.knownIds, id)) return null; + + const separator = id.lastIndexOf(EFFORT_ROW_SEPARATOR); + if (separator <= 0) return null; + const baseId = id.slice(0, separator); + const effort = id.slice(separator + EFFORT_ROW_SEPARATOR.length); + // "none" is never published as a row (discovery filters it), so it is never accepted either. + if (effort === "none" || !isDeclaredReasoningEffort(effort)) return null; + if (predictCursorEffort(baseId, options.table ?? null, options.supportsReasoning).ladder !== null) { + return null; + } + return { baseId, effort }; +} + +/** Parse one ingress selector against the current config and installed Cursor table. */ +export function parseRequestEffortRowId(id: string, config: OcxConfig): ParsedEffortRowId | null { + if (config.cursorEffortRows !== true) return null; + // Ordinary ids carry no separator; bail before the known-id scan and install detection so + // the flag costs nothing on the request path for models that are not effort rows. + if (id.lastIndexOf(EFFORT_ROW_SEPARATOR) <= 0) return null; + return parseEffortRowId(id, config, { + knownIds: knownEffortRowIds(config), + table: loadDetectedCursorEffortTable(), + }); +} + +export function expandCursorEffortRow( + row: T, + efforts: readonly string[] | undefined, + config: Pick, + options: EffortRowOptions = {}, +): T[] { + if (config.cursorEffortRows !== true) return [row]; + + const supported = canonicalizeReasoningEfforts( + (efforts ?? []).filter(effort => effort !== "none" && isDeclaredReasoningEffort(effort)), + ); + const supportsReasoning = options.supportsReasoning ?? supported.length > 0; + if (predictCursorEffort(row.id, options.table ?? null, supportsReasoning).ladder !== null) { + return [row]; + } + return [ + row, + ...supported + .map(effort => effortRowId(row.id, effort)) + .filter(id => !isKnownId(options.knownIds, id)) + .map(id => ({ ...row, id })), + ]; +} diff --git a/src/server/index.ts b/src/server/index.ts index 0e072634ce..92495a8b58 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -227,6 +227,9 @@ import { detectInstall } from "../update/index"; import { readyProtocolMetadata } from "../remote/protocol"; import { modelCapabilityFields } from "./models-capabilities"; import { recordCursorSeen } from "../integrations/cursor-seen"; +import { detectCursorInstalls } from "../integrations/cursor-detect"; +import { loadCursorEffortTable } from "../integrations/cursor-effort-table"; +import { expandCursorEffortRow, knownEffortRowIds } from "./effort-row"; export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -1566,45 +1569,69 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server install.build === "private-inference") + : undefined; + const cursorEffortTable = effortRowsEnabled + ? (deps.managementApi?.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference) + : null; + const expandedNativeModelRow = (id: string, metadataId = id) => { + const reasoningEfforts = nativeReasoningEfforts(metadataId); + return expandCursorEffortRow(nativeModelRow(id, metadataId), reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table: cursorEffortTable, + supportsReasoning: reasoningEfforts.length > 0, + }); + }; + const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // Same rule as the anthropic branch: with the global fast switch on, a client + // that has no Fast toggle is offered the fast identity directly. An operator + // alias is an explicit decision and still wins. + const fastModelId = cursorFastIdForListing?.(m.id, m.provider); + const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`; + const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); + const provider = config.providers[m.provider]; + const effective = provider + ? (await import("../providers/default-aliases")).effectiveModelAliases( + config, + provider, + knownModelIdsForProvider(m.provider, provider, config), + ).get(m.id) + : undefined; + const row = { + id: publicId, + object: "model", + created: 0, + // This endpoint is an OpenAI-compatible inbound contract. Some clients use + // owned_by as an adapter selector, so a virtual combo must name that wire + // adapter rather than the internal catalog authority marker. + owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), + ...(isCombo ? { is_combo: true } : {}), + ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), + ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), + ...modelCapabilityFields({ + reasoningEfforts: m.reasoningEfforts, + // contextWindow is already the post-cap effective value; contextCap is the raw + // operator knob and over-reports models whose real window sits below it. + contextWindow: m.contextWindow, + maxOutputTokens: m.maxOutputTokens, + inputModalities: m.inputModalities, + }), + }; + return expandCursorEffortRow(row, m.reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table: cursorEffortTable, + supportsReasoning: (m.reasoningEfforts ?? []).length > 0, + }); + })); const data = [ - ...visibleNatives.map(id => nativeModelRow(id)), - ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), - ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { - // Same rule as the anthropic branch: with the global fast switch on, a client - // that has no Fast toggle is offered the fast identity directly. An operator - // alias is an explicit decision and still wins. - const fastModelId = cursorFastIdForListing?.(m.id, m.provider); - const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`; - const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); - const provider = config.providers[m.provider]; - const effective = provider - ? (await import("../providers/default-aliases")).effectiveModelAliases( - config, - provider, - knownModelIdsForProvider(m.provider, provider, config), - ).get(m.id) - : undefined; - return { - id: publicId, - object: "model", - created: 0, - // This endpoint is an OpenAI-compatible inbound contract. Some clients use - // owned_by as an adapter selector, so a virtual combo must name that wire - // adapter rather than the internal catalog authority marker. - owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), - ...(isCombo ? { is_combo: true } : {}), - ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), - ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), - ...modelCapabilityFields({ - reasoningEfforts: m.reasoningEfforts, - // contextWindow is already the post-cap effective value; contextCap is the raw - // operator knob and over-reports models whose real window sits below it. - contextWindow: m.contextWindow, - maxOutputTokens: m.maxOutputTokens, - inputModalities: m.inputModalities, - }), - }; - })), + ...visibleNatives.flatMap(id => expandedNativeModelRow(id)), + ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)), + ...routedRows.flat(), ]; return jsonResponse({ object: "list", data }, 200, req, policy); } diff --git a/src/server/management/cursor-integration-routes.ts b/src/server/management/cursor-integration-routes.ts index f4fc69a3d2..46f39209f9 100644 --- a/src/server/management/cursor-integration-routes.ts +++ b/src/server/management/cursor-integration-routes.ts @@ -16,6 +16,7 @@ import { loadCursorEffortTable } from "../../integrations/cursor-effort-table"; import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors"; import { fetchAllModels } from "../management-api"; import { predictCursorEffort } from "../models-capabilities"; +import { expandCursorEffortRow, knownEffortRowIds } from "../effort-row"; import type { ManagementContext } from "./context"; export const CURSOR_GATEWAY_PLACEHOLDER_KEY = "opencodex-loopback"; @@ -31,6 +32,8 @@ export interface CursorIntegrationStatus { id: string; reasoning: string[] | null; family: string | null; + tableLess: boolean; + effortRows: string[]; context: { defaultWindow: number; longWindow: number } | null; }>; guideUrl: string; @@ -63,21 +66,32 @@ export async function buildCursorIntegrationStatus( const goModels = filterCatalogVisibleModels(await fetchAllModels(config), config); // supportsReasoning mirrors what the /v1/models row advertises (a non-empty ladder); the // gemini family withholds its control when it is false. - const ids: Array<{ id: string; supportsReasoning: boolean }> = [ - ...visibleNativeSlugs(config).map(id => ({ id, supportsReasoning: nativeReasoningEfforts(id).length > 0 })), + const ids: Array<{ id: string; supportsReasoning: boolean; reasoningEfforts: readonly string[] }> = [ + ...visibleNativeSlugs(config).map(id => { + const reasoningEfforts = nativeReasoningEfforts(id); + return { id, supportsReasoning: reasoningEfforts.length > 0, reasoningEfforts }; + }), ...uniqueCatalogModelsForRawPublicList(goModels).map(model => ({ id: model.alias ?? `${model.provider}/${model.id}`, supportsReasoning: (model.reasoningEfforts ?? []).length > 0, + reasoningEfforts: model.reasoningEfforts ?? [], })), ]; const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); - const models = ids.map(({ id, supportsReasoning }) => { + const effortRowKnownIds = config.cursorEffortRows === true ? knownEffortRowIds(config) : undefined; + const models = ids.map(({ id, supportsReasoning, reasoningEfforts }) => { const tier = nativeOpenAiContextTier(id, limits); const predicted = predictCursorEffort(id, table, supportsReasoning); return { id, reasoning: predicted.ladder, family: predicted.family, + tableLess: predicted.ladder === null, + effortRows: expandCursorEffortRow({ id }, reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table, + supportsReasoning, + }).slice(1).map(row => row.id), context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, }; }); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e2e23f241d..24c6d4021d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -326,6 +326,7 @@ import { restoreImageGenCallsInJson, } from "../responses-image-gen-repair"; import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; +import { parseRequestEffortRowId } from "../effort-row"; import { collectSelfNamedNamespaceScrubAuthorization, createSelfNamedToolCallNamespaceScrubRewrite, @@ -2697,6 +2698,23 @@ async function handleResponsesInner( } return decodeRequestErrorResponse(err, "responses"); } + // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher + // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model. + const comboEffortRow = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body) + && typeof (body as { model?: unknown }).model === "string" + ? parseRequestEffortRowId((body as { model: string }).model, config) + : null; + if (comboEffortRow) { + const raw = body as Record; + raw.model = comboEffortRow.baseId; + const rawReasoning = raw.reasoning; + raw.reasoning = { + ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning) + ? rawReasoning as Record + : {}), + effort: comboEffortRow.effort, + }; + } const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); @@ -2751,6 +2769,20 @@ async function handleResponsesInner( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + const effortRow = parseRequestEffortRowId(parsed.modelId, config); + if (effortRow) { + parsed.modelId = effortRow.baseId; + parsed.options.reasoning = effortRow.effort; + const raw = parsed._rawBody as Record; + const rawReasoning = raw.reasoning; + raw.model = effortRow.baseId; + raw.reasoning = { + ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning) + ? rawReasoning as Record + : {}), + effort: effortRow.effort, + }; + } if (options.comboReplaySnapshot?.recoveredPlaintext) { markBodyNonPersistable(parsed._rawBody); } diff --git a/src/types/config.ts b/src/types/config.ts index 9bead2ad10..5292586d77 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -366,6 +366,12 @@ export interface OcxConfig { }; /** Enable the shipped model alias patterns for providers without an override. */ defaultModelAliases?: boolean; + /** + * Opt-in Cursor Private Inference compatibility rows. When true, `/v1/models` + * adds `--` selectors for reasoning-capable model ids absent + * from Cursor's built-in effort table. Omitted/false preserves discovery output. + */ + cursorEffortRows?: boolean; /** Explicit top-level deletion intent used by stale whole-config rebases. */ configRebaseProvenance?: OcxConfigRebaseProvenance | Record; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ diff --git a/tests/cursor-effort-rows.test.ts b/tests/cursor-effort-rows.test.ts new file mode 100644 index 0000000000..4929696af8 --- /dev/null +++ b/tests/cursor-effort-rows.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { saveConfig } from "../src/config"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; +import { buildCursorIntegrationStatus } from "../src/server/management/cursor-integration-routes"; +import { handleChatCompletions } from "../src/server/chat-completions"; +import { handleClaudeMessages } from "../src/server/claude-messages"; +import { + effortRowId, + expandCursorEffortRow, + parseEffortRowId, +} from "../src/server/effort-row"; +import { handleResponses } from "../src/server/responses"; +import { startServer } from "../src/server"; +import type { RequestLogContext } from "../src/server/request-log"; +import type { OcxConfig } from "../src/types"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; + +setDefaultTimeout(SERVER_BUDGET_MS); + +const previousHome = process.env.OPENCODEX_HOME; +let testHome = ""; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-cursor-effort-rows-")); + process.env.OPENCODEX_HOME = testHome; +}); + +afterEach(() => { + resetCodexModelEntitlementCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testHome) removeTreeWithRetry(testHome); + testHome = ""; +}); + +function discoveryConfig(cursorEffortRows?: boolean): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "anthropic", + ...(cursorEffortRows === undefined ? {} : { cursorEffortRows }), + providers: { + anthropic: { + adapter: "openai-chat", + baseUrl: "https://anthropic.test/v1", + liveModels: false, + models: ["claude-fable-5-1", "claude-opus-5"], + modelReasoningEfforts: { + "claude-fable-5-1": ["none", "low", "high", "max"], + "claude-opus-5": ["low", "high", "max"], + }, + }, + cursor: { + adapter: "openai-chat", + baseUrl: "https://cursor.test/v1", + liveModels: false, + models: ["kimi-k3", "gpt-5.6-sol"], + modelReasoningEfforts: { + "kimi-k3": ["minimal", "medium", "ultra"], + "gpt-5.6-sol": ["low", "medium", "high", "xhigh"], + }, + }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + }, + }, + }; +} + +async function rawModelList(config: OcxConfig): Promise<{ text: string; data: Array> }> { + saveConfig(config); + const server = startServer(0, { managementApi: { loadCursorEffortTable: () => null } }); + try { + const response = await fetch(new URL("/v1/models", server.url)); + expect(response.status).toBe(200); + const text = await response.text(); + return { text, data: (JSON.parse(text) as { data: Array> }).data }; + } finally { + await server.stop(true); + } +} + +function mockChatUpstream(): { server: ReturnType; captured: Array> } { + const captured: Array> = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const body = await req.json() as Record; + captured.push(body); + if (body.stream !== true) { + return Response.json({ + id: "chatcmpl_effort_row", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } + return new Response([ + 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\n', + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + return { server, captured }; +} + +function ingressConfig(baseUrl: string): OcxConfig { + return { + port: 0, + cursorEffortRows: true, + defaultProvider: "fixture", + subagentEffortCap: "high", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl, + apiKey: "fixture-key", + allowPrivateNetwork: true, + liveModels: false, + models: ["claude-effort-row-fixture"], + modelReasoningEfforts: { + "claude-effort-row-fixture": ["low", "high", "max"], + }, + }, + }, + }; +} + +const childHeaders = { + "content-type": "application/json", + "x-openai-subagent": "collab_spawn", +}; + +describe("Cursor effort variant rows", () => { + test("parseEffortRowId enables only the -- grammar behind cursorEffortRows", () => { + expect(parseEffortRowId("kimi/k3--high", {})).toBeNull(); + expect(parseEffortRowId("kimi/k3--high", { cursorEffortRows: false })).toBeNull(); + for (const id of ["kimi/k3@high", "kimi/k3:high", "kimi/k3-high", "kimi/k3--", "kimi/k3--turbo", "kimi/k3--none"]) { + expect(parseEffortRowId(id, { cursorEffortRows: true })).toBeNull(); + } + expect(parseEffortRowId("kimi/k3--high", { cursorEffortRows: true })).toEqual({ + baseId: "kimi/k3", + effort: "high", + }); + expect(parseEffortRowId("kimi/k3--high", { cursorEffortRows: true }, { + knownIds: new Set(["kimi/k3--high"]), + })).toBeNull(); + }); + + test("Cursor-table model ids never become effort rows", () => { + expect(parseEffortRowId("anthropic/claude-opus-5--high", { cursorEffortRows: true })).toBeNull(); + expect(parseEffortRowId("gpt-5.6-sol--high", { cursorEffortRows: true })).toBeNull(); + }); + + test("cursorEffortRows false is byte-identical to an omitted flag", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); + const omitted = await rawModelList(discoveryConfig()); + const disabled = await rawModelList(discoveryConfig(false)); + expect(disabled.text).toBe(omitted.text); + }); + + test("raw model discovery clones one complete row per supported effort only for table-less ids", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); + const { data } = await rawModelList(discoveryConfig(true)); + const ids = data.map(row => row.id); + expect(ids).toContain("anthropic/claude-fable-5-1--low"); + expect(ids).toContain("anthropic/claude-fable-5-1--high"); + expect(ids).toContain("anthropic/claude-fable-5-1--max"); + expect(ids).not.toContain("anthropic/claude-fable-5-1--none"); + expect(ids).toContain("cursor/kimi-k3--minimal"); + expect(ids).toContain("cursor/kimi-k3--medium"); + expect(ids).toContain("cursor/kimi-k3--ultra"); + expect(ids.some(id => id === "anthropic/claude-opus-5--high")).toBe(false); + expect(ids.some(id => id === "cursor/gpt-5.6-sol--high")).toBe(false); + + for (const baseId of ["anthropic/claude-fable-5-1", "cursor/kimi-k3"]) { + const base = data.find(row => row.id === baseId)!; + const variants = data.filter(row => typeof row.id === "string" && row.id.startsWith(`${baseId}--`)); + const { id: _baseId, ...baseRest } = base; + for (const variant of variants) { + const { id: _variantId, ...variantRest } = variant; + expect(variantRest).toEqual(baseRest); + } + } + + expect(expandCursorEffortRow( + { id: "table-less", marker: { nested: true } }, + ["none", "high"], + { cursorEffortRows: true }, + )).toEqual([ + { id: "table-less", marker: { nested: true } }, + { id: "table-less--high", marker: { nested: true } }, + ]); + }); + + test("Responses effort rows route the base model and pass through the existing cap", async () => { + const upstream = mockChatUpstream(); + try { + const config = ingressConfig(`${upstream.server.url.toString().replace(/\/$/u, "")}/v1`); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: childHeaders, + body: JSON.stringify({ + model: "fixture/claude-effort-row-fixture--max", + stream: false, + input: "hello", + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(upstream.captured[0]).toMatchObject({ + model: "claude-effort-row-fixture", + reasoning_effort: "high", + }); + } finally { + upstream.server.stop(true); + } + }); + + test("Chat effort rows use Responses normalization instead of the native-chat shortcut", async () => { + const upstream = mockChatUpstream(); + try { + const config = ingressConfig(`${upstream.server.url.toString().replace(/\/$/u, "")}/v1`); + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: childHeaders, + body: JSON.stringify({ + model: "fixture/claude-effort-row-fixture--max", + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(upstream.captured[0]).toMatchObject({ + model: "claude-effort-row-fixture", + reasoning_effort: "high", + }); + } finally { + upstream.server.stop(true); + } + }); + + test("Messages effort rows resolve after route directives and before native passthrough", async () => { + const upstream = mockChatUpstream(); + try { + const config = ingressConfig(`${upstream.server.url.toString().replace(/\/$/u, "")}/v1`); + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + ...childHeaders, + "x-api-key": "native-fixture-credential", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-fallback-model", + max_tokens: 128, + stream: false, + system: [{ type: "text", text: "" }], + messages: [{ role: "user", content: "hello" }], + }), + }), config, { model: "", provider: "" } as RequestLogContext); + expect(response.status).toBe(200); + await response.text(); + expect(upstream.captured[0]).toMatchObject({ + model: "claude-effort-row-fixture", + reasoning_effort: "high", + }); + } finally { + upstream.server.stop(true); + } + }); + + test("Cursor integration status marks table-less bases and reports generated row ids", async () => { + const config = discoveryConfig(true); + const status = await buildCursorIntegrationStatus({ + config, + deps: { + loadCursorEffortTable: () => null, + readRuntimePort: () => null, + }, + }, []); + const fable = status.models.find(model => model.id === "anthropic/claude-fable-5-1")!; + expect(fable.tableLess).toBe(true); + expect(fable.effortRows).toEqual([ + effortRowId(fable.id, "low"), + effortRowId(fable.id, "high"), + effortRowId(fable.id, "max"), + ]); + const kimi = status.models.find(model => model.id === "cursor/kimi-k3")!; + expect(kimi.tableLess).toBe(true); + expect(kimi.effortRows).toEqual([ + effortRowId(kimi.id, "minimal"), + effortRowId(kimi.id, "medium"), + effortRowId(kimi.id, "ultra"), + ]); + for (const id of ["anthropic/claude-opus-5", "cursor/gpt-5.6-sol"]) { + const model = status.models.find(row => row.id === id)!; + expect(model.tableLess).toBe(false); + expect(model.effortRows).toEqual([]); + } + }); +}); diff --git a/tests/cursor-integration-status.test.ts b/tests/cursor-integration-status.test.ts index 93ad11a7e4..35348dc933 100644 --- a/tests/cursor-integration-status.test.ts +++ b/tests/cursor-integration-status.test.ts @@ -166,12 +166,21 @@ describe("GET /api/native-integrations/cursor", () => { expect(typeof first.privateInference.installed).toBe("boolean"); expect(first.guideUrl).toContain("cursor-private-inference"); const k3 = first.models.find(model => model.id === "kimi/k3"); - expect(k3).toEqual({ id: "kimi/k3", reasoning: null, family: null, context: null }); + expect(k3).toEqual({ + id: "kimi/k3", + reasoning: null, + family: null, + tableLess: true, + effortRows: [], + context: null, + }); const sol = first.models.find(model => model.id === "gpt-5.6-sol"); expect(sol).toEqual({ id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], family: null, + tableLess: false, + effortRows: [], context: { defaultWindow: 272000, longWindow: 922000 }, });