diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index b619d162e8..061d3725b8 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -10,6 +10,11 @@ import type { CursorEffortTable } from "../integrations/cursor-effort-table"; * Completions, Responses and Anthropic Messages, streams, and accepts tool calls, so those are * constants; context length and vision come from catalog data when known and are omitted * otherwise, matching Cursor's optional-field schema. + * + * Top-level capacity metrics (`context_window`, `context_length`, `max_output_tokens`) are + * mirrored directly on each model row for external client discovery (e.g. pi-ai, DSH, + * LibreChat) that inspects flat properties rather than Cursor's nested `capabilities.*` shape. + * A row that gains a nested capacity value must gain the top-level mirror in the same change. */ /** @@ -132,6 +137,19 @@ export interface ModelCapabilityFields { supports_vision?: boolean; reasoning_effort?: string[]; }; + /** + * Mirrored top-level context window for external/legacy client discovery (e.g. pi-ai, DSH) + * that reads top-level context_window / context_length instead of nested capabilities. + */ + context_window?: number; + /** + * Top-level context length alias matching capabilities.context_length for clients expecting context_length. + */ + context_length?: number; + /** + * Mirrored top-level max output token limit for external/legacy client discovery. + */ + max_output_tokens?: number; /** * Cursor reads the long-context threshold from `pricing.overrides[].min_prompt_tokens`. That * key sits outside its validated capability schema, so it is the one place a threshold can @@ -153,6 +171,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil const longContextLength = positiveInt(input.longContextWindow); const maxOutputTokens = positiveInt(input.maxOutputTokens); const hasLongTier = contextLength !== undefined && longContextLength !== undefined && longContextLength > contextLength; + const effectiveContextLength = hasLongTier ? longContextLength : contextLength; const modalities = Array.isArray(input.inputModalities) ? input.inputModalities.filter(modality => typeof modality === "string" && modality.length > 0) : undefined; @@ -160,9 +179,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil return { api_types: [...OPENCODEX_MODEL_API_TYPES], capabilities: { - ...(hasLongTier - ? { context_length: longContextLength } - : contextLength !== undefined ? { context_length: contextLength } : {}), + ...(effectiveContextLength !== undefined ? { context_length: effectiveContextLength } : {}), ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}), // Once a gateway advertises api_types, Cursor keeps only rows whose output_modalities // include "text"; omitting the key drops the row from the extended catalog. @@ -174,6 +191,10 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil ...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}), ...(efforts.length > 0 ? { reasoning_effort: [...efforts] } : {}), }, + ...(effectiveContextLength !== undefined + ? { context_window: effectiveContextLength, context_length: effectiveContextLength } + : {}), + ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}), ...(hasLongTier ? { pricing: { overrides: [{ min_prompt_tokens: contextLength }] } } : {}), }; } diff --git a/structure/catalog.md b/structure/catalog.md index 03636900b0..b6cea5318b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -180,6 +180,10 @@ then trusted catalog metadata such as a configured qualified provider/model alia This overlay never changes route identity or the upstream wire model, and its catalog fingerprint makes a label edit refresh Codex output. +Raw `/v1/models` rows advertise positive safe capacity values in both Cursor's nested +`capabilities` object and top-level discovery fields used by other clients. A model with a larger +opt-in context tier uses that effective long window in both shapes; invalid values are omitted. + Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync and convergence pass the same map to the observed-state merge. After native normalization and ordering, the merge applies the exact nonblank trimmed label and saves diff --git a/tests/providers/cursor/cursor-local-models-schema.test.ts b/tests/providers/cursor/cursor-local-models-schema.test.ts index 93c4be1840..2d8e0fb8c7 100644 --- a/tests/providers/cursor/cursor-local-models-schema.test.ts +++ b/tests/providers/cursor/cursor-local-models-schema.test.ts @@ -123,6 +123,35 @@ describe("modelCapabilityFields", () => { expect(modelCapabilityFields({ maxOutputTokens: 1.9 }).capabilities.supports_reasoning) .toBe(false); }); + + test("mirrors top-level context_window and max_output_tokens for external and legacy client discovery", () => { + const fields = modelCapabilityFields({ contextWindow: 200000, maxOutputTokens: 64000 }); + expect(fields.context_window).toBe(200000); + expect(fields.context_length).toBe(200000); + expect(fields.max_output_tokens).toBe(64000); + expect(fields.capabilities.context_length).toBe(200000); + expect(fields.capabilities.max_output_tokens).toBe(64000); + + // Empty or non-positive / unsafe inputs: keys omitted entirely + const empty = modelCapabilityFields({}); + expect("context_window" in empty).toBe(false); + expect("context_length" in empty).toBe(false); + expect("max_output_tokens" in empty).toBe(false); + + for (const value of [0, -50, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) { + const fields = modelCapabilityFields({ contextWindow: value, maxOutputTokens: value }); + expect("context_window" in fields).toBe(false); + expect("context_length" in fields).toBe(false); + expect("max_output_tokens" in fields).toBe(false); + } + }); + + test("mirrors the effective long context window at the top level", () => { + const fields = modelCapabilityFields({ contextWindow: 272000, longContextWindow: 922000 }); + expect(fields.capabilities.context_length).toBe(922000); + expect(fields.context_window).toBe(922000); + expect(fields.context_length).toBe(922000); + }); }); describe("nativeOpenAiContextTier", () => { @@ -165,6 +194,9 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => supports_vision: true, reasoning_effort: ["low", "high", "max"], }); + expect(k3!.context_window).toBe(200000); + expect(k3!.context_length).toBe(200000); + expect(k3!.max_output_tokens).toBe(64_000); // Grok Build's discovery fields stay untouched next to the new keys. expect(k3!.supports_reasoning_effort).toBe(true); expect(k3!.reasoning_effort).toBe("high"); @@ -186,6 +218,9 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => // Native GPT-5.6: 272k default window, 922k opt-in ceiling → Cursor Context selector. expect(solCaps.context_length).toBe(922000); expect(solCaps.max_output_tokens).toBe(128_000); + expect(sol!.context_window).toBe(922000); + expect(sol!.context_length).toBe(922000); + expect(sol!.max_output_tokens).toBe(128_000); expect(sol!.pricing).toEqual({ overrides: [{ min_prompt_tokens: 272000 }] }); expect("long_context_threshold_tokens" in sol!).toBe(false); expect(solCaps.supports_vision).toBe(true);