Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 17 additions & 18 deletions src/models/available-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -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:
Expand All @@ -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: {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -293,7 +303,7 @@ function buildStructuralParameterMetadata(
const metadata = new Map<string, ParameterMetadata>();
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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
Expand Down
23 changes: 20 additions & 3 deletions src/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -91,10 +104,14 @@ let cachedModels: CursorModel[] | null = null;
*/
export async function getCursorModels(apiKey: string): Promise<CursorModel[]> {
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;
}
Expand Down
95 changes: 95 additions & 0 deletions src/models/model-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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, string>,
): 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<string, unknown>,
): 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<string, unknown> | undefined): string | undefined {
const tooltip = asRecord(source?.tooltipData);
return typeof tooltip?.markdownContent === "string"
? tooltip.markdownContent
: undefined;
}

export function inferAvailableContextWindow(
model: Record<string, unknown>,
variantContext?: string,
variant?: Record<string, unknown>,
): 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<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
7 changes: 5 additions & 2 deletions src/openai/request-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/provider/config-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -84,7 +85,7 @@ export async function resolveConfigModels(): Promise<CursorModel[]> {
try {
discovered = await withTimeout(
getCursorModels(accessToken),
15_000,
CONFIG_MODEL_DISCOVERY_TIMEOUT_MS,
);
} catch (err) {
const summary = err instanceof Error ? err.message : String(err);
Expand Down
25 changes: 19 additions & 6 deletions src/provider/model-descriptor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CursorModel } from "../models.js";
import { buildInputModalities } from "../models/model-capabilities.js";
import {
CURSOR_PROVIDER_ID,
CURSOR_VARIANT_OPTION,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -79,7 +86,7 @@ function buildProviderModel(
input: {
text: true,
audio: false,
image: true,
image: supportsImages,
video: false,
pdf: false,
},
Expand All @@ -93,7 +100,7 @@ function buildProviderModel(
interleaved: false,
},
modalities: {
input: ["text", "image"],
input: inputModalities,
output: ["text"],
},
cost: estimateModelCost(model.id),
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Loading