-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Open
Description
as I'm adding more models, I tend to run into issues with having to make a bunch of model picker changes and it's annoying. that in conjunction with some providers restricting models per user (e.g. spark not being accesible to eveyrone based on auth method and sub tier) is tedious.
we should make the model picker list server authorative, with the provider outputting the available models
Codex: https://developers.openai.com/codex/app-server#list-models-modellist
Claude: don't think they expose any, so provider would have to output a static list
Cursor: POST /agent.v1.AgentService/GetUsableModels (doesn't seem like they expose it over acp yet
probe
/**
* Probe: connect to Cursor Agent via ACP (stdio NDJSON JSON-RPC 2.0)
* and retrieve the available model list.
*
* Usage: bun run scripts/cursor-acp-models-probe.ts
*
* Requires: `cursor-agent` on PATH and authenticated (`agent login`).
*/
import { spawn, type Subprocess } from "bun"
// ── JSON-RPC types ────────────────────────────────────────────────
interface JsonRpcRequest {
jsonrpc: "2.0"
id: number
method: string
params: Record<string, unknown>
}
interface JsonRpcMessage {
jsonrpc: "2.0"
id?: number
method?: string
result?: unknown
error?: { code: number; message: string; data?: unknown }
params?: unknown
}
// ── NDJSON transport over stdin/stdout ─────────────────────────────
class NdjsonTransport {
private reader: ReadableStreamDefaultReader<Uint8Array>
private writer: ReturnType<Subprocess["stdin"] & object>
private decoder = new TextDecoder()
private buf = ""
private pending: JsonRpcMessage[] = []
private waiters: Array<(msg: JsonRpcMessage) => void> = []
private done = false
constructor(
stdout: ReadableStream<Uint8Array>,
stdin: NonNullable<Subprocess["stdin"]>,
) {
this.reader = stdout.getReader()
this.writer = stdin
void this.pump()
}
private async pump() {
try {
while (true) {
const { done, value } = await this.reader.read()
if (done) break
this.buf += this.decoder.decode(value, { stream: true })
let nl: number
while ((nl = this.buf.indexOf("\n")) !== -1) {
const line = this.buf.slice(0, nl).trim()
this.buf = this.buf.slice(nl + 1)
if (!line) continue
try {
const msg = JSON.parse(line) as JsonRpcMessage
if (this.waiters.length > 0) {
this.waiters.shift()!(msg)
} else {
this.pending.push(msg)
}
} catch {
console.error("[transport] bad JSON:", line.slice(0, 200))
}
}
}
} finally {
this.done = true
// unblock all waiters
for (const w of this.waiters) {
w({ jsonrpc: "2.0", error: { code: -1, message: "stream closed" } })
}
this.waiters = []
}
}
send(method: string, params: Record<string, unknown>, id: number) {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }
this.writer.write(new TextEncoder().encode(JSON.stringify(msg) + "\n"))
this.writer.flush()
}
/** Read next message (any kind). */
next(): Promise<JsonRpcMessage> {
if (this.pending.length > 0) return Promise.resolve(this.pending.shift()!)
if (this.done)
return Promise.resolve({
jsonrpc: "2.0",
error: { code: -1, message: "stream closed" },
})
return new Promise((resolve) => this.waiters.push(resolve))
}
/** Read messages until we get a response with the given id. Notifications are logged. */
async waitFor(id: number): Promise<JsonRpcMessage> {
while (true) {
const msg = await this.next()
if (msg.id === id) return msg
if (msg.error?.code === -1) return msg
if (msg.method) {
console.log(` [notification] ${msg.method}`)
}
}
}
}
// ── Main ──────────────────────────────────────────────────────────
const AGENT_BIN = process.env.CURSOR_AGENT ?? "cursor-agent"
const TIMEOUT_MS = 30_000
console.log(`[probe] spawning: ${AGENT_BIN} acp\n`)
const proc: Subprocess = spawn([AGENT_BIN, "acp"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
const transport = new NdjsonTransport(
proc.stdout as ReadableStream<Uint8Array>,
proc.stdin!,
)
// Drain stderr in background
void (async () => {
const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader()
const dec = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = dec.decode(value, { stream: true }).trim()
if (text) console.error(` [agent stderr] ${text}`)
}
})()
let nextId = 1
const timeout = setTimeout(() => {
console.error("[probe] timed out")
proc.kill()
process.exit(1)
}, TIMEOUT_MS)
try {
// ── 1. Initialize ───────────────────────────────────────────────
console.log("→ initialize")
transport.send(
"initialize",
{
protocolVersion: 1,
clientInfo: { name: "t3-acp-probe", version: "0.1.0" },
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
},
},
nextId,
)
const initResp = await transport.waitFor(nextId++)
if (initResp.error) {
console.error("✗ initialize failed:", initResp.error)
process.exit(1)
}
console.log("← initialized")
console.log(JSON.stringify(initResp.result, null, 2))
// ── 2. Authenticate ─────────────────────────────────────────────
console.log("\n→ authenticate (cursor_login)")
transport.send("authenticate", { methodId: "cursor_login" }, nextId)
const authResp = await transport.waitFor(nextId++)
if (authResp.error) {
console.error("✗ authenticate failed:", authResp.error)
console.error(" (run `agent login` first)")
process.exit(1)
}
console.log("← authenticated")
// ── 3. session/new → get models ─────────────────────────────────
console.log("\n→ session/new")
transport.send(
"session/new",
{ cwd: process.cwd(), mcpServers: [] },
nextId,
)
const sessionResp = await transport.waitFor(nextId++)
if (sessionResp.error) {
console.error("✗ session/new failed:", sessionResp.error)
process.exit(1)
}
const session = sessionResp.result as {
sessionId: string
models?: {
currentModelId: string
availableModels: Array<{ modelId: string; name: string }>
}
modes?: {
currentModeId: string
availableModes: Array<{
id: string
name: string
description?: string
}>
}
configOptions?: Array<{
id: string
name: string
description?: string
type: string
currentValue?: string
options?: Array<{ value: string; name: string; description?: string }>
}>
}
console.log(`← session created: ${session.sessionId}`)
// ── 4. Print models ─────────────────────────────────────────────
if (session.models) {
const { currentModelId, availableModels } = session.models
console.log(`\n╔══ Available Models (${availableModels.length}) ══╗`)
console.log(`║ current: ${currentModelId}`)
console.log("║")
for (const m of availableModels) {
const marker = m.modelId === currentModelId ? " ◀" : ""
console.log(`║ ${m.modelId.padEnd(45)} ${m.name}${marker}`)
}
console.log("╚" + "═".repeat(50) + "╝")
}
// Config option version (may contain variant-level detail)
const modelConfig = session.configOptions?.find((c) => c.id === "model")
if (modelConfig?.options) {
console.log(
`\n╔══ Model Config Option (${modelConfig.options.length} variants) ══╗`,
)
console.log(`║ current: ${modelConfig.currentValue}`)
console.log("║")
for (const opt of modelConfig.options) {
const marker = opt.value === modelConfig.currentValue ? " ◀" : ""
console.log(`║ ${opt.value.padEnd(45)} ${opt.name}${marker}`)
}
console.log("╚" + "═".repeat(50) + "╝")
}
// ── 5. Print modes and other config ─────────────────────────────
if (session.modes) {
console.log("\nModes:", session.modes.availableModes.map((m) => m.id).join(", "))
console.log("Current mode:", session.modes.currentModeId)
}
const otherConfigs = session.configOptions?.filter((c) => c.id !== "model")
if (otherConfigs?.length) {
console.log("\nOther config options:")
for (const c of otherConfigs) {
console.log(` ${c.id} = ${c.currentValue} (${c.type})`)
}
}
// ── 6. Full raw dump ────────────────────────────────────────────
console.log("\n── raw session/new response ──")
console.log(JSON.stringify(session, null, 2))
} finally {
clearTimeout(timeout)
proc.kill()
}Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels