diff --git a/src/model-selection.ts b/src/model-selection.ts index 5665388..6c76298 100644 --- a/src/model-selection.ts +++ b/src/model-selection.ts @@ -19,6 +19,8 @@ export interface CursorModel { id: string; name: string; reasoning: boolean; + /** From Cursor AvailableModels `supportsImages` when known. */ + supportsImages?: boolean; contextWindow: number; maxTokens: number; defaultSelection: CursorModelSelection; diff --git a/src/models/available-normalizer.ts b/src/models/available-normalizer.ts index 79f6cbc..9e36f8b 100644 --- a/src/models/available-normalizer.ts +++ b/src/models/available-normalizer.ts @@ -4,9 +4,14 @@ import type { CursorModelSelection, } from "../model-selection.js"; import { - DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, } from "../shared/constants.js"; +import { + extractAvailableModelCapabilities, + inferAvailableContextWindow, + isEffortParameterId, + readRawEffortValue, +} from "./model-capabilities.js"; interface VariantDescriptor { key: string; idSuffixes: readonly string[]; @@ -95,6 +100,7 @@ export function normalizeAvailableModels(models: readonly unknown[]): CursorMode if (!model || !name) continue; const displayName = pickAvailableDisplayName(model, name); + const capabilities = extractAvailableModelCapabilities(model); const serverModelName = stringProp(model, "serverModelName") ?? name; const definitions = arrayProp(model, "parameterDefinitions") .map(asRecord) @@ -112,7 +118,7 @@ export function normalizeAvailableModels(models: readonly unknown[]): CursorMode const parameters = parseParameterValues(variant.parameterValues); const values = new Map(parameters.map((parameter) => [parameter.id, parameter.value])); const context = values.get("context"); - const rawEffort = values.get("reasoning") ?? values.get("effort"); + const rawEffort = readRawEffortValue(values); const effort = normalizeEffort(rawEffort); if (rawEffort && !effort) continue; const structuralParts = buildStructuralParts(values, structuralParameters); @@ -136,12 +142,14 @@ export function normalizeAvailableModels(models: readonly unknown[]): CursorMode parameters, maxMode: variant.isMaxMode === true, }; + const inferredContext = inferAvailableContextWindow(model, context, variant); const group = groups.get(groupKey) ?? { id: groupId, name: groupName, - contextWindow: parseTokenLimit(context) ?? DEFAULT_CONTEXT_WINDOW, + contextWindow: inferredContext, selections: [], }; + group.contextWindow = Math.max(group.contextWindow, inferredContext); group.selections.push({ effort, isDefault: @@ -164,8 +172,9 @@ export function normalizeAvailableModels(models: readonly unknown[]): CursorMode const candidate: CursorModel = { id: name, name: displayName, - reasoning: model.supportsThinking === true, - contextWindow: DEFAULT_CONTEXT_WINDOW, + reasoning: capabilities.supportsThinking, + supportsImages: capabilities.supportsImages, + contextWindow: inferAvailableContextWindow(model), maxTokens: DEFAULT_MAX_TOKENS, defaultSelection: flatSelection, variants: {}, @@ -206,6 +215,7 @@ export function normalizeAvailableModels(models: readonly unknown[]): CursorMode id: publicId, name: group.name, reasoning: Object.keys(variantsByEffort).length > 0, + supportsImages: capabilities.supportsImages, contextWindow: group.contextWindow, maxTokens: DEFAULT_MAX_TOKENS, defaultSelection: defaultEntry.selection, @@ -293,7 +303,7 @@ function buildStructuralParameterMetadata( const metadata = new Map(); for (const [index, definition] of definitions.entries()) { const id = stringProp(definition, "id"); - if (!id || id === "reasoning" || id === "effort") continue; + if (!id || isEffortParameterId(id)) continue; const values = parameterDefinitionValues(definition); metadata.set(id, { id, @@ -311,7 +321,7 @@ function buildStructuralParameterMetadata( for (const variant of variants) { for (const parameter of parseParameterValues(variant.parameterValues)) { - if (parameter.id === "reasoning" || parameter.id === "effort") continue; + if (isEffortParameterId(parameter.id)) continue; const existing = metadata.get(parameter.id); if (existing) { existing.baseline ??= parameter.value; @@ -416,17 +426,6 @@ function normalizeIdPart(value: string): string { return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } -function parseTokenLimit(value: string | undefined): number | undefined { - if (!value) return undefined; - const normalized = value.trim().toLowerCase().replace(/,/g, ""); - const match = normalized.match(/^(\d+(?:\.\d+)?)([km])?$/); - if (!match) return undefined; - const amount = Number(match[1]); - if (!Number.isFinite(amount) || amount <= 0) return undefined; - const multiplier = match[2] === "m" ? 1_000_000 : match[2] === "k" ? 1_000 : 1; - return Math.round(amount * multiplier); -} - function asRecord(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) diff --git a/src/models/catalog.ts b/src/models/catalog.ts index 6d8014d..2e00b9b 100644 --- a/src/models/catalog.ts +++ b/src/models/catalog.ts @@ -7,6 +7,8 @@ import { import { normalizeAvailableModels } from "./available-normalizer.js"; import type { CursorModel } from "../model-selection.js"; import { normalizeCursorModels } from "./usable-normalizer.js"; +import { AVAILABLE_MODELS_RPC_TIMEOUT_MS } from "../shared/constants.js"; +import { log } from "../shared/log.js"; const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels"; const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels"; @@ -40,8 +42,14 @@ async function fetchCursorAvailableModels( requestBody, contentType: "application/json", connectProtocolVersion: "1", + timeoutMs: AVAILABLE_MODELS_RPC_TIMEOUT_MS, }); if (response.timedOut || response.exitCode !== 0 || response.body.length === 0) { + if (response.timedOut) { + log.warn( + `[opencode-cursor] AvailableModels timed out after ${AVAILABLE_MODELS_RPC_TIMEOUT_MS}ms; falling back to GetUsableModels`, + ); + } return null; } @@ -50,6 +58,11 @@ async function fetchCursorAvailableModels( const models = Array.isArray(record?.models) ? normalizeAvailableModels(record.models) : []; + if (models.length > 0) { + log.info( + `[opencode-cursor] discovered ${models.length} models via AvailableModels`, + ); + } return models.length > 0 ? models : null; } catch { return null; @@ -91,10 +104,14 @@ let cachedModels: CursorModel[] | null = null; */ export async function getCursorModels(apiKey: string): Promise { if (cachedModels) return cachedModels; - const discovered = - (await fetchCursorAvailableModels(apiKey)) ?? - (await fetchCursorUsableModels(apiKey)); + const available = await fetchCursorAvailableModels(apiKey); + const discovered = available ?? (await fetchCursorUsableModels(apiKey)); if (discovered && discovered.length > 0) { + if (!available) { + log.warn( + `[opencode-cursor] using GetUsableModels fallback (${discovered.length} models; capability metadata may be incomplete)`, + ); + } cachedModels = discovered; return cachedModels; } diff --git a/src/models/model-capabilities.ts b/src/models/model-capabilities.ts new file mode 100644 index 0000000..87c6697 --- /dev/null +++ b/src/models/model-capabilities.ts @@ -0,0 +1,95 @@ +import { DEFAULT_CONTEXT_WINDOW } from "../shared/constants.js"; + +const EFFORT_PARAMETER_IDS = ["reasoning", "effort", "reasoning_effort"] as const; + +export function isEffortParameterId(id: string): boolean { + return (EFFORT_PARAMETER_IDS as readonly string[]).includes(id); +} + +export function readRawEffortValue( + values: ReadonlyMap, +): string | undefined { + for (const id of EFFORT_PARAMETER_IDS) { + const value = values.get(id); + if (value) return value; + } + return undefined; +} + +export interface AvailableModelCapabilities { + supportsImages: boolean; + supportsThinking: boolean; +} + +export function extractAvailableModelCapabilities( + model: Record, +): AvailableModelCapabilities { + return { + supportsImages: model.supportsImages !== false, + supportsThinking: model.supportsThinking === true, + }; +} + +export function parseContextFromTooltip( + markdown: string | undefined, +): number | undefined { + if (!markdown) return undefined; + const match = markdown.match(/(\d+(?:\.\d+)?)\s*([km])\b/i); + if (!match) return undefined; + return parseTokenLimit(`${match[1]}${match[2].toLowerCase()}`); +} + +export function parseTokenLimit(value: string | undefined): number | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase().replace(/,/g, ""); + const match = normalized.match(/^(\d+(?:\.\d+)?)([km])?$/); + if (!match) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) return undefined; + const multiplier = + match[2] === "m" ? 1_000_000 : match[2] === "k" ? 1_000 : 1; + return Math.round(amount * multiplier); +} + +function readTooltipMarkdown(source: Record | undefined): string | undefined { + const tooltip = asRecord(source?.tooltipData); + return typeof tooltip?.markdownContent === "string" + ? tooltip.markdownContent + : undefined; +} + +export function inferAvailableContextWindow( + model: Record, + variantContext?: string, + variant?: Record, +): number { + const fromVariant = parseTokenLimit(variantContext); + if (fromVariant) return fromVariant; + + const maxModeLimit = positiveNumber(model.contextTokenLimitForMaxMode); + if (maxModeLimit) return maxModeLimit; + + const fromVariantTooltip = parseContextFromTooltip(readTooltipMarkdown(variant)); + if (fromVariantTooltip) return fromVariantTooltip; + + const fromTooltip = parseContextFromTooltip(readTooltipMarkdown(model)); + if (fromTooltip) return fromTooltip; + + return DEFAULT_CONTEXT_WINDOW; +} + +export function buildInputModalities(supportsImages: boolean): string[] { + return supportsImages ? ["text", "image"] : ["text"]; +} + +function positiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.round(value) + : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/src/openai/request-classifier.ts b/src/openai/request-classifier.ts index 4a4da56..f81d47f 100644 --- a/src/openai/request-classifier.ts +++ b/src/openai/request-classifier.ts @@ -14,9 +14,12 @@ export function isTitleGenerationRequest(messages: OpenAIMessage[]): boolean { .filter((m) => m.role === "system") .map((m) => textContent(m.content)) .join(" "); + const normalized = systemText.toLowerCase(); return ( - systemText.toLowerCase().includes("title generator") || - systemText.toLowerCase().includes("generate a short title") + normalized.includes("title generator") || + normalized.includes("generate a short title") || + normalized.includes("generate a brief title") || + normalized.includes("output only a thread title") ); } diff --git a/src/provider/config-models.ts b/src/provider/config-models.ts index 4750ede..a83e221 100644 --- a/src/provider/config-models.ts +++ b/src/provider/config-models.ts @@ -10,6 +10,7 @@ import { LOGIN_PLACEHOLDER_MODELS, type CursorModel, } from "../models.js"; +import { CONFIG_MODEL_DISCOVERY_TIMEOUT_MS } from "../shared/constants.js"; import { log } from "../shared/log.js"; /** Reject a promise if it does not settle within `ms` milliseconds. */ @@ -84,7 +85,7 @@ export async function resolveConfigModels(): Promise { try { discovered = await withTimeout( getCursorModels(accessToken), - 15_000, + CONFIG_MODEL_DISCOVERY_TIMEOUT_MS, ); } catch (err) { const summary = err instanceof Error ? err.message : String(err); diff --git a/src/provider/model-descriptor.ts b/src/provider/model-descriptor.ts index 23fcd54..b56fa65 100644 --- a/src/provider/model-descriptor.ts +++ b/src/provider/model-descriptor.ts @@ -1,4 +1,5 @@ import type { CursorModel } from "../models.js"; +import { buildInputModalities } from "../models/model-capabilities.js"; import { CURSOR_PROVIDER_ID, CURSOR_VARIANT_OPTION, @@ -10,6 +11,10 @@ import { } from "../shared/constants.js"; import { estimateModelCost } from "./pricing.js"; +function modelSupportsImages(model: CursorModel): boolean { + return model.supportsImages !== false; +} + function selectDefaultCursorModel( models: CursorModel[], ): CursorModel | undefined { @@ -52,6 +57,8 @@ function buildProviderModel( const contextWindow = model.contextWindow > 0 ? model.contextWindow : DEFAULT_CONTEXT_WINDOW; const maxTokens = model.maxTokens > 0 ? model.maxTokens : DEFAULT_MAX_TOKENS; + const supportsImages = modelSupportsImages(model); + const inputModalities = buildInputModalities(supportsImages); return { id, providerID: CURSOR_PROVIDER_ID, @@ -79,7 +86,7 @@ function buildProviderModel( input: { text: true, audio: false, - image: true, + image: supportsImages, video: false, pdf: false, }, @@ -93,7 +100,7 @@ function buildProviderModel( interleaved: false, }, modalities: { - input: ["text", "image"], + input: inputModalities, output: ["text"], }, cost: estimateModelCost(model.id), @@ -138,6 +145,7 @@ export function buildConfigModelEntries( model.contextWindow > 0 ? model.contextWindow : DEFAULT_CONTEXT_WINDOW; const maxTokens = model.maxTokens > 0 ? model.maxTokens : DEFAULT_MAX_TOKENS; + const inputModalities = buildInputModalities(modelSupportsImages(model)); entries[model.id] = { name: model.name, // OpenCode prepends generic low/medium/high variants for reasoning-capable @@ -147,15 +155,16 @@ export function buildConfigModelEntries( // reasoning output and routing are handled by the local proxy. reasoning: false, tool_call: true, + attachment: true, // Required for OpenCode's static config path: without modalities.input // including "image", attachments are stripped before they reach the proxy. modalities: { - input: ["text", "image"], + input: inputModalities, output: ["text"], }, capabilities: { tools: true, - input: ["text", "image"], + input: inputModalities, output: ["text"], }, cost: estimateModelCost(model.id), @@ -183,17 +192,21 @@ export function buildConfigModelEntries( defaultModel.maxTokens > 0 ? defaultModel.maxTokens : DEFAULT_MAX_TOKENS; + const defaultInputModalities = buildInputModalities( + modelSupportsImages(defaultModel), + ); entries[DEFAULT_MODEL_ID] = { name: `Default (${defaultModel.name})`, reasoning: false, tool_call: true, + attachment: true, modalities: { - input: ["text", "image"], + input: defaultInputModalities, output: ["text"], }, capabilities: { tools: true, - input: ["text", "image"], + input: defaultInputModalities, output: ["text"], }, cost: estimateModelCost(defaultModel.id), diff --git a/src/proxy.ts b/src/proxy.ts index a2d29ec..4f8dea6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -118,6 +118,7 @@ import { } from "./conversation/identity.js"; import { BridgePool, type BridgeHandle } from "./bridge-pool.js"; import { log } from "./shared/log.js"; +import { NAME_AGENT_RPC_TIMEOUT_MS } from "./shared/constants.js"; import { CURSOR_SELECTION_HEADER, decodeCursorModelSelection, @@ -910,8 +911,18 @@ async function nameConversationViaCursor( accessToken, rpcPath: NAME_AGENT_PATH, requestBody, + timeoutMs: NAME_AGENT_RPC_TIMEOUT_MS, }); - if (response.timedOut || response.exitCode !== 0 || response.body.length === 0) { + if (response.timedOut) { + log.warn( + `[proxy] NameAgent timed out after ${NAME_AGENT_RPC_TIMEOUT_MS}ms — session title will stay default`, + ); + return null; + } + if (response.exitCode !== 0 || response.body.length === 0) { + log.warn( + `[proxy] NameAgent failed exit=${response.exitCode} bodyBytes=${response.body.length}`, + ); return null; } const name = decodeNameAgentResponse(response.body)?.name?.trim(); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3072cb0..822d1dc 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -8,6 +8,13 @@ export const CURSOR_VARIANT_OPTION = "cursorVariant"; export const DEFAULT_CONTEXT_WINDOW = 200_000; export const DEFAULT_MAX_TOKENS = 64_000; +/** AvailableModels can exceed the default 5s RPC budget on slow links. */ +export const AVAILABLE_MODELS_RPC_TIMEOUT_MS = 30_000; +/** Outer config discovery timeout — slightly above RPC to avoid racing the bridge. */ +export const CONFIG_MODEL_DISCOVERY_TIMEOUT_MS = 35_000; +/** NameAgent title RPC — default 5s is too short; live calls often need ~6s+. */ +export const NAME_AGENT_RPC_TIMEOUT_MS = 15_000; + export const GENERATED_VARIANT_KEYS = [ "none", "minimal", diff --git a/test/smoke.ts b/test/smoke.ts index 98e9427..5c09cdb 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -718,6 +718,117 @@ async function testAvailableModelParameterGrouping(modules: TestModules) { "Grok Code Fast 1", "Expected tooltip title for named Grok models", ); + assertEqual( + namedModels.find((model) => model.id === "grok-code-fast-1")?.contextWindow, + 256_000, + "Expected tooltip context window parsing", + ); + assertEqual( + namedModels.find((model) => model.id === "grok-4-5")?.reasoning, + true, + "Expected supportsThinking on flat models", + ); + + const geminiModels = modules.normalizeAvailableModels([ + { + name: "gemini-3.8-flash", + serverModelName: "gemini-3.8-flash", + clientDisplayName: "Gemini 3.8 Flash", + supportsImages: true, + supportsThinking: true, + parameterDefinitions: [ + enumParameter("reasoning_effort", [ + { value: "low" }, + { value: "medium" }, + { value: "high" }, + ]), + ], + variants: ["low", "medium", "high"].map((effort) => ({ + parameterValues: [{ id: "reasoning_effort", value: effort }], + legacySlug: `gemini-3.8-flash-${effort}`, + isDefaultNonMaxConfig: effort === "medium", + })), + }, + ]); + assertEqual( + geminiModels.length, + 1, + "Expected Gemini model with reasoning_effort variants", + ); + assertArrayEqual( + Object.keys(geminiModels[0]!.variants), + ["low", "medium", "high"], + "Expected reasoning_effort values to normalize as effort variants", + ); + assertEqual( + geminiModels[0]!.supportsImages, + true, + "Expected supportsImages from AvailableModels", + ); + + const textOnlyModels = modules.normalizeAvailableModels([ + { + name: "text-only-model", + serverModelName: "text-only-model", + supportsImages: false, + supportsThinking: false, + }, + ]); + assertEqual( + textOnlyModels[0]!.supportsImages, + false, + "Expected explicit supportsImages=false", + ); + + const maxContextModels = modules.normalizeAvailableModels([ + { + name: "max-context-model", + serverModelName: "max-context-model", + contextTokenLimitForMaxMode: 272_000, + }, + ]); + assertEqual( + maxContextModels[0]!.contextWindow, + 272_000, + "Expected contextTokenLimitForMaxMode when variant context is absent", + ); + + const variantTooltipModels = modules.normalizeAvailableModels([ + { + name: "gemini-3.8-flash", + serverModelName: "gemini-3.8-flash", + clientDisplayName: "Gemini 3.8 Flash", + supportsImages: true, + parameterDefinitions: [ + enumParameter("reasoning_effort", [{ value: "high" }]), + ], + variants: [ + { + parameterValues: [{ id: "reasoning_effort", value: "high" }], + legacySlug: "gemini-3.8-flash-high", + tooltipData: { + markdownContent: + "**Gemini 3.8 Flash**
Great for daily use.

1M context window", + }, + }, + ], + }, + ]); + assertEqual( + variantTooltipModels[0]!.contextWindow, + 1_000_000, + "Expected variant tooltip context window parsing", + ); + + const { buildConfigModelEntries } = await import( + "../src/provider/model-descriptor" + ); + const configEntries = buildConfigModelEntries(textOnlyModels); + assertArrayEqual( + configEntries["text-only-model"]!.modalities.input, + ["text"], + "Expected config descriptor to omit image when supportsImages=false", + ); console.log("[test] Parameter-aware AvailableModels grouping OK"); } diff --git a/test/unit/extracted-helpers.ts b/test/unit/extracted-helpers.ts index 1dfd5a4..d06b275 100644 --- a/test/unit/extracted-helpers.ts +++ b/test/unit/extracted-helpers.ts @@ -56,6 +56,17 @@ export async function runExtractedHelperUnitTests(): Promise { ]), "title detection", ); + assert( + isTitleGenerationRequest([ + { + role: "system", + content: + "You are a title generator. Output ONLY a thread title.\nGenerate a brief title that helps the user find this conversation later.", + }, + { role: "user", content: "hello" }, + ]), + "OpenCode 1.18 title prompt detection", + ); assert( isSummaryGenerationRequest([ {