Skip to content
Source
Sign inSign up
Open History to see the last commit

CursorSdkCatalog.ts

197 lines7.0 KB

1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import * as Cache from "effect/Cache";
3import * as Config from "effect/Config";
4import * as Option from "effect/Option";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import * as HttpClientResponse from "effect/http/HttpClientResponse";
8import * as Context from "effect/Context";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";
14​
15export interface CursorSdkCatalogSnapshot {
16 readonly user: SDKUser | undefined;
17 readonly models: ReadonlyArray<SDKModel>;
19​
20export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()(
21 "CursorSdkCatalogError",
22 {
23 authenticationFailure: Schema.Boolean,
24 status: Schema.optionalKey(Schema.Number),
25 code: Schema.optionalKey(Schema.String),
26 cause: Schema.Defect(),
27 },
28) {
29 override get message(): string {
30 return this.authenticationFailure
31 ? "Cursor SDK authentication failed."
32 : "Cursor SDK catalog request failed.";
33 }
34​
35 get planRequired(): boolean {
36 return this.code === "plan_required";
37 }
38​
39 get accountBlocked(): boolean {
40 return this.status === 403 && !this.planRequired;
41 }
43​
44export class CursorSdkCatalog extends Context.Service<
45 CursorSdkCatalog,
46 {
47 readonly read: (
48 apiKey: string,
49 ) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
50 }
51>()("@t3tools/provider-cursor/server/CursorSdkCatalog") {}
52​
53export interface CursorSdkCatalogProbes {
54 readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
55 readonly readModels: (
56 apiKey: string,
57 ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>;
59​
60const CatalogUser = Schema.Struct({
61 apiKeyName: Schema.String,
62 createdAt: Schema.String,
63 userId: Schema.optionalKey(Schema.Number),
64 userEmail: Schema.optionalKey(Schema.String),
65 userFirstName: Schema.optionalKey(Schema.String),
66 userLastName: Schema.optionalKey(Schema.String),
67});
68const ModelParameterValue = Schema.Struct({ id: Schema.String, value: Schema.String });
69const CatalogModel = Schema.Struct({
70 id: Schema.String,
71 displayName: Schema.String,
72 description: Schema.optionalKey(Schema.String),
73 aliases: Schema.optionalKey(Schema.Array(Schema.String).pipe(Schema.mutable)),
74 parameters: Schema.optionalKey(
75 Schema.Array(
76 Schema.Struct({
77 id: Schema.String,
78 displayName: Schema.optionalKey(Schema.String),
79 values: Schema.Array(
80 Schema.Struct({ value: Schema.String, displayName: Schema.optionalKey(Schema.String) }),
81 ).pipe(Schema.mutable),
82 }),
83 ).pipe(Schema.mutable),
84 ),
85 variants: Schema.optionalKey(
86 Schema.Array(
87 Schema.Struct({
88 params: Schema.Array(ModelParameterValue).pipe(Schema.mutable),
89 displayName: Schema.String,
90 description: Schema.optionalKey(Schema.String),
91 isDefault: Schema.optionalKey(Schema.Boolean),
92 isMaxMode: Schema.optionalKey(Schema.Boolean),
93 }),
94 ).pipe(Schema.mutable),
95 ),
96});
97const CatalogModels = Schema.Struct({ items: Schema.Array(CatalogModel).pipe(Schema.mutable) });
98const ApiError = Schema.Struct({
99 code: Schema.optionalKey(Schema.String),
100 message: Schema.optionalKey(Schema.String),
101});
102const ApiErrorEnvelope = Schema.Struct({ error: ApiError });
103const decodeApiError = Schema.decodeUnknownOption(ApiError);
104const decodeApiErrorEnvelope = Schema.decodeUnknownOption(ApiErrorEnvelope);
105const isCatalogError = Schema.is(CursorSdkCatalogError);
106​
107// Catalog discovery uses public HTTP routes so signed-in startup does not initialize the agent runtime.
108const makeLiveProbes = Effect.gen(function* () {
109 const client = yield* HttpClient.HttpClient;
110 const baseUrl = (yield* Config.String("CURSOR_BACKEND_URL").pipe(
111 Config.withDefault("https://api.cursor.com"),
112 Effect.orDie,
113 )).replace(/\/$/, "");
114 const request = Effect.fn("CursorSdkCatalog.request")(function* (apiKey: string, path: string) {
115 const response = yield* client
116 .execute(
117 HttpClientRequest.get(`${baseUrl}${path}`).pipe(HttpClientRequest.bearerToken(apiKey)),
118 )
119 .pipe(
120 Effect.mapError(
121 (cause) => new CursorSdkCatalogError({ authenticationFailure: false, cause }),
122 ),
123 );
124 if (response.status < 200 || response.status >= 300) {
125 const body = yield* response.json.pipe(Effect.orElseSucceed(() => undefined));
126 const nested = decodeApiErrorEnvelope(body);
127 const parsed = Option.isSome(nested) ? Option.some(nested.value.error) : decodeApiError(body);
128 const error = Option.getOrUndefined(parsed);
129 return yield* new CursorSdkCatalogError({
130 authenticationFailure: response.status === 401,
131 status: response.status,
132 ...(error?.code === undefined ? {} : { code: error.code }),
133 cause: new Error(error?.message ?? `Cursor ${path} returned HTTP ${response.status}.`),
134 });
135 }
136 return response;
137 });
138 return {
139 readUser: (apiKey) =>
140 request(apiKey, "/v1/me").pipe(
141 Effect.flatMap(HttpClientResponse.schemaBodyJson(CatalogUser)),
142 Effect.mapError((cause) =>
143 isCatalogError(cause)
144 ? cause
145 : new CursorSdkCatalogError({ authenticationFailure: false, cause }),
146 ),
147 ),
148 readModels: (apiKey) =>
149 request(apiKey, "/v1/models").pipe(
150 Effect.flatMap(HttpClientResponse.schemaBodyJson(CatalogModels)),
151 Effect.map((body) => body.items),
152 Effect.mapError((cause) =>
153 isCatalogError(cause)
154 ? cause
155 : new CursorSdkCatalogError({ authenticationFailure: false, cause }),
156 ),
157 ),
158 } satisfies CursorSdkCatalogProbes;
159});
160​
161export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
162 probes: CursorSdkCatalogProbes,
163) {
164 const modelCache = yield* Cache.makeWith(probes.readModels, {
165 capacity: 32,
166 timeToLive: (exit) =>
167 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
168 });
169 return CursorSdkCatalog.of({
170 read: Effect.fn("CursorSdkCatalog.read")(function (apiKey) {
171 return Effect.all(
172 {
173 // Enterprise teams can block account labels while model discovery still works.
174 user: Effect.catchIf(
175 probes.readUser(apiKey),
176 (error: CursorSdkCatalogError): boolean => error.accountBlocked,
177 () => Effect.undefined,
178 (error: CursorSdkCatalogError) => Effect.fail(error),
179 ),
180 models: Cache.get(modelCache, apiKey),
181 },
182 { concurrency: "unbounded" },
183 );
184 }),
185 });
186});
187​
188export const layer = Layer.effect(
189 CursorSdkCatalog,
190 Effect.flatMap(makeLiveProbes, makeCursorSdkCatalog),
191);
192​
193export function layerTest(
194 read: CursorSdkCatalog["Service"]["read"],
195): Layer.Layer<CursorSdkCatalog> {
196 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));