Skip to content

providers should expose models as structured data #1386

@juliusmarminge

Description

@juliusmarminge

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()
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions