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
1 change: 1 addition & 0 deletions .cursor/cookbook
Submodule cookbook added at df5f5a
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule ".cursor/cookbook"]
path = .cursor/cookbook
url = https://github.com/cursor/cookbook.git
1 change: 1 addition & 0 deletions packages/app/src/hooks/use-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const popularProviders = [
"google",
"openrouter",
"vercel",
"cursor",
]
const popularProviderSet = new Set(popularProviders)

Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/cli/cmd/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ export const ProvidersLoginCommand = cmd({
anthropic: 4,
openrouter: 5,
vercel: 6,
cursor: 7,
}
const pluginProviders = resolvePluginProviders({
hooks,
Expand All @@ -374,6 +375,11 @@ export const ProvidersLoginCommand = cmd({
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
{
label: "Cursor (Cookbook / Cloud)",
value: "cursor",
hint: "CURSOR_API_KEY + Cloud Agents API",
},
...pipe(
providers,
values(),
Expand Down Expand Up @@ -463,6 +469,12 @@ export const ProvidersLoginCommand = cmd({
prompts.log.info("Create an api key at https://opencode.ai/auth")
}

if (provider === "cursor") {
prompts.log.info("Cursor Cookbook: https://github.com/cursor/cookbook")
prompts.log.info("Create a Cursor API key: https://cursor.com/dashboard/integrations")
prompts.log.info("Docs: https://cursor.com/docs/api/sdk/typescript")
}

if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
Expand All @@ -473,6 +485,20 @@ export const ProvidersLoginCommand = cmd({
)
}

if (provider === "cursor") {
const key = await prompts.password({
message: "Paste your Cursor API key (CURSOR_API_KEY)",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put("cursor", {
type: "api",
key,
})
prompts.outro("Done")
return
}

const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
Expand Down
116 changes: 109 additions & 7 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import { withStatics } from "@/util/schema"

import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { defaultCursorModelsDevProvider } from "./cursor/models-dev"
import { fetchCursorModelIds } from "./cursor/fetch-models"
import { CursorCloudAgentLanguageModel } from "./cursor/cloud-agent-language-model"

const log = Log.create({ service: "provider" })

Expand Down Expand Up @@ -822,6 +825,81 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
},
}),
cursor: Effect.fnUntraced(function* (input: Info) {
const authRecord = yield* dep.auth("cursor").pipe(Effect.orDie)
const env = yield* dep.env()
const apiKey = (authRecord?.type === "api" ? authRecord.key : undefined) ?? env["CURSOR_API_KEY"]

return {
autoload: Boolean(apiKey),
options: {
baseURL: "https://api.cursor.com/v1",
...(typeof input.options?.repoUrl === "string" ? { repoUrl: input.options.repoUrl } : {}),
...(typeof input.options?.startingRef === "string" ? { startingRef: input.options.startingRef } : {}),
},
async discoverModels(): Promise<Record<string, Model>> {
if (!apiKey) {
log.info("cursor model discovery skipped: no apiKey")
return {}
}
try {
const ids = await fetchCursorModelIds(apiKey)
const models: Record<string, Model> = {}
for (const id of ids) {
if (input.models[id]) continue
const parsedModel: Model = {
id: ModelID.make(id),
providerID: ProviderID.make("cursor"),
name: id,
family: "",
api: {
id,
url: "https://api.cursor.com/v1",
npm: "@ai-sdk/openai-compatible",
},
status: "active",
headers: {},
options: {},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200_000, output: 32_000 },
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: false,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {},
}
parsedModel.variants = mapValues(
pickBy(ProviderTransform.variants(parsedModel), (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
models[id] = parsedModel
}
log.info("cursor model discovery complete", {
count: Object.keys(models).length,
models: Object.keys(models),
})
return models
} catch (e) {
log.warn("cursor model discovery failed", { error: e })
return {}
}
},
async getModel(_sdk: unknown, modelID: string, options?: Record<string, unknown>) {
const key = typeof options?.apiKey === "string" ? options.apiKey : apiKey ?? ""
return new CursorCloudAgentLanguageModel({
modelId: modelID,
apiKey: key,
providerOptions: options ?? {},
})
},
}
}),
kilo: () =>
Effect.succeed({
autoload: false,
Expand Down Expand Up @@ -1089,7 +1167,11 @@ const layer: Layer.Layer<
using _ = log.time("state")
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
const modelsDev = yield* Effect.promise(async () => {
const raw = await ModelsDev.get()
if (raw["cursor"]) return raw
return { ...raw, cursor: defaultCursorModelsDevProvider() }
})
const database = mapValues(modelsDev, fromModelsDevProvider)

const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
Expand Down Expand Up @@ -1326,6 +1408,22 @@ const layer: Layer.Layer<
})
}

const cursorProvider = ProviderID.make("cursor")
if (discoveryLoaders[cursorProvider] && providers[cursorProvider] && isProviderAllowed(cursorProvider)) {
yield* Effect.promise(async () => {
try {
const discovered = await discoveryLoaders[cursorProvider]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[cursorProvider].models[modelID]) {
providers[cursorProvider].models[modelID] = model
}
}
} catch (e) {
log.warn("state discovery error", { id: "cursor", error: e })
}
})
}

for (const hook of plugins) {
const p = hook.provider
const models = p?.models
Expand Down Expand Up @@ -1580,20 +1678,24 @@ const layer: Layer.Layer<
const s = yield* InstanceState.get(state)
const envs = yield* env.all()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
if (model.providerID !== "cursor" && s.models.has(key)) return s.models.get(key)!

return yield* Effect.promise(async () => {
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s, envs)

try {
const mergedOptions = {
...provider.options,
...model.options,
...(model.providerID === "cursor" && provider.key ? { apiKey: provider.key } : {}),
}
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options,
...model.options,
})
? await s.modelLoaders[model.providerID](sdk, model.api.id, mergedOptions)
: sdk.languageModel(model.api.id)
s.models.set(key, language)
if (model.providerID !== "cursor") {
s.models.set(key, language)
}
return language
} catch (e) {
if (e instanceof NoSuchModelError)
Expand Down
23 changes: 21 additions & 2 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { PermissionID } from "@/permission/schema"
import { Bus } from "@/bus"
import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema"
import { setActiveCursorSession } from "@/provider/cursor/active-session"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
Expand Down Expand Up @@ -110,6 +111,12 @@ const live: Layer.Layer<
.join("\n"),
)

if (input.model.providerID === "cursor") {
system.push(
"Note: Cursor Cloud runs in Cursor's VM with Cursor-side tools; OpenCode tool calls are not forwarded for this provider.",
)
}

const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
Expand Down Expand Up @@ -193,7 +200,11 @@ const live: Layer.Layer<
},
)

const tools = resolveTools(input)
let tools = resolveTools(input)
const isCursor = input.model.providerID === "cursor"
if (isCursor) {
tools = {}
}

// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
Expand All @@ -211,6 +222,7 @@ const live: Layer.Layer<
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
!isCursor &&
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
Expand Down Expand Up @@ -363,7 +375,7 @@ const live: Layer.Layer<
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
toolChoice: isCursor ? ("none" as const) : input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
Expand Down Expand Up @@ -421,6 +433,13 @@ const live: Layer.Layer<
(ctrl) => Effect.sync(() => ctrl.abort()),
)

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (input.model.providerID === "cursor") setActiveCursorSession(undefined)
}),
)
if (input.model.providerID === "cursor") setActiveCursorSession(input.sessionID)

const result = yield* run({ ...input, abort: ctrl.signal })

return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
Expand Down
Loading