diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9c99508fd..dc8eec6bc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor `max_mode` requests to send discovered max-mode metadata on both model payload fields. ([#4797](https://github.com/can1357/oh-my-pi/issues/4797)) + ## [16.3.11] - 2026-07-06 ### Fixed diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index b7cb3e3de..0963c240f 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -76,6 +76,7 @@ import { RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, + RequestedModelSchema, ResumeActionSchema, SelectedContextSchema, SelectedImageSchema, @@ -2790,16 +2791,24 @@ function buildGrpcRequest( turns, }); + const wireModelId = model.requestModelId ?? model.id; + const cursorMaxMode = model.cursorMaxMode === true; const modelDetails = create(ModelDetailsSchema, { - modelId: model.id, + modelId: wireModelId, displayModelId: model.id, displayName: model.name, + ...(cursorMaxMode ? { maxMode: true } : undefined), + }); + const requestedModel = create(RequestedModelSchema, { + modelId: wireModelId, + maxMode: cursorMaxMode, }); const runRequest = create(AgentRunRequestSchema, { conversationState, action, modelDetails, + requestedModel, conversationId: state.conversationId, }); diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index e77bc484f..f510560ea 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -23,9 +23,23 @@ const cursorModel: Model<"cursor-agent"> = buildModel({ maxTokens: 1, }); -function captureCursorPayload(context: Context): Promise { +const cursorMaxModeModel: Model<"cursor-agent"> = buildModel({ + id: "cursor-composer-2.5-max", + name: "Cursor Composer 2.5 Max", + api: "cursor-agent", + provider: "cursor", + baseUrl: "", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1, + maxTokens: 1, + cursorMaxMode: true, +}); + +function captureCursorPayload(context: Context, model: Model<"cursor-agent"> = cursorModel): Promise { const { promise, resolve, reject } = Promise.withResolvers(); - streamCursor(cursorModel, context, { + streamCursor(model, context, { apiKey: "test-token", onPayload: payload => { if (isAgentRunRequest(payload)) { @@ -165,6 +179,19 @@ describe("Cursor request action encoding", () => { expect(payload.action?.action.case).toBe("userMessageAction"); }); + it("sends Cursor max-mode metadata on model details and requested model", async () => { + const payload = await captureCursorPayload( + { + messages: [{ role: "user", content: "continue", timestamp: 0 }], + }, + cursorMaxModeModel, + ); + + expect(payload.modelDetails?.maxMode).toBe(true); + expect(payload.requestedModel?.modelId).toBe("cursor-composer-2.5-max"); + expect(payload.requestedModel?.maxMode).toBe(true); + }); + it("uses a resume action when a tool result is the final context message", async () => { const payload = await captureCursorPayload(toolResultContext()); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 42e39218a..0f27c6b51 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor discovery to preserve `GetUsableModels` max-mode metadata for premium models. ([#4797](https://github.com/can1357/oh-my-pi/issues/4797)) + ## [16.3.11] - 2026-07-06 ### Added diff --git a/packages/catalog/src/discovery/cursor.ts b/packages/catalog/src/discovery/cursor.ts index 880aefec5..266f63c0a 100644 --- a/packages/catalog/src/discovery/cursor.ts +++ b/packages/catalog/src/discovery/cursor.ts @@ -28,6 +28,7 @@ const CursorModelDetailsSchema = type({ displayModelId: OptionalDisplayNameSchema.default(undefined), aliases: CursorAliasesSchema.default(() => []), "thinkingDetails?": "unknown", + maxMode: "boolean = false", }); const CursorModelsInnerSchema = type("unknown[]"); @@ -283,6 +284,7 @@ function normalizeCursorModel( name, baseUrl: baseUrlOverride ?? reference.baseUrl, reasoning, + cursorMaxMode: details.maxMode, }; } return { @@ -296,6 +298,7 @@ function normalizeCursorModel( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: DEFAULT_CONTEXT_WINDOW, maxTokens: DEFAULT_MAX_TOKENS, + cursorMaxMode: details.maxMode, }; } diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 103549d0f..c33b5c48f 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -711,6 +711,8 @@ export interface Model { supportsTools?: boolean; /** GitLab Duo Workflow root namespace selected during catalog discovery. */ gitlabDuoWorkflowRootNamespaceId?: string; + /** Cursor `max_mode` request flag returned by `GetUsableModels` for premium models that require max mode. */ + cursorMaxMode?: boolean; cost: { input: number; // $/million tokens output: number; // $/million tokens diff --git a/packages/catalog/test/cursor-discovery.test.ts b/packages/catalog/test/cursor-discovery.test.ts new file mode 100644 index 000000000..e74860f3a --- /dev/null +++ b/packages/catalog/test/cursor-discovery.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as http2 from "node:http2"; +import type * as net from "node:net"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { fetchCursorUsableModels } from "@oh-my-pi/pi-catalog/discovery/cursor"; +import { GetUsableModelsResponseSchema, ModelDetailsSchema } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; + +const servers = new Set(); + +afterEach(async () => { + await Promise.all( + [...servers].map(server => { + const { promise, resolve, reject } = Promise.withResolvers(); + server.close(error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + return promise; + }), + ); + servers.clear(); +}); + +function requireTcpAddress(address: string | net.AddressInfo | null): net.AddressInfo { + if (!address || typeof address === "string") { + throw new Error("HTTP/2 test server did not bind to a TCP address"); + } + return address; +} + +function startCursorDiscoveryServer(body: Uint8Array): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const server = http2.createServer(); + servers.add(server); + server.once("error", reject); + server.on("stream", (stream: http2.ServerHttp2Stream) => { + stream.respond({ ":status": 200, "content-type": "application/proto" }); + stream.end(Buffer.from(body)); + }); + server.listen(0, "127.0.0.1", () => { + resolve(`http://127.0.0.1:${requireTcpAddress(server.address()).port}`); + }); + return promise; +} + +describe("fetchCursorUsableModels", () => { + it("preserves Cursor max-mode metadata from GetUsableModels", async () => { + const response = create(GetUsableModelsResponseSchema, { + models: [ + create(ModelDetailsSchema, { + modelId: "cursor-composer-max", + displayName: "Cursor Composer Max", + maxMode: true, + }), + ], + }); + const baseUrl = await startCursorDiscoveryServer(toBinary(GetUsableModelsResponseSchema, response)); + + const models = await fetchCursorUsableModels({ apiKey: "test-token", baseUrl, timeoutMs: 1_000 }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "cursor-composer-max", + name: "Cursor Composer Max", + api: "cursor-agent", + provider: "cursor", + cursorMaxMode: true, + }), + ]); + }); +});