Initial segregate: proxy source, deploy manifests, ImagePolicy Flux CRs.
build-and-push / build (push) Failing after 2m45s

Move workload out of infra with cursor-devbox-style GitRepository +
run_number-sha image automation.
This commit is contained in:
Vsevolod Sauta
2026-09-21 10:31:36 +03:00
commit 57a036ba5e
22 changed files with 2444 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
name: build-and-push
on:
push:
branches: [master]
paths:
- 'Dockerfile'
- 'entrypoint.sh'
- 'src/**'
- 'scripts/**'
- 'cursor-bootstrap/**'
- '.gitea/workflows/build-and-push.yaml'
workflow_dispatch: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: dcr.trinitysan.by
username: ${{ secrets.DCR_USERNAME }}
password: ${{ secrets.DCR_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest
dcr.trinitysan.by/infra/cursor-agent-api-proxy:${{ github.sha }}
dcr.trinitysan.by/infra/cursor-agent-api-proxy:${{ github.run_number }}-${{ github.sha }}
cache-from: type=registry,ref=dcr.trinitysan.by/infra/cursor-agent-api-proxy:build-cache
cache-to: type=registry,ref=dcr.trinitysan.by/infra/cursor-agent-api-proxy:build-cache,mode=max
+3
View File
@@ -0,0 +1,3 @@
.env
*.log
node_modules/
+51
View File
@@ -0,0 +1,51 @@
# cursor-agent-api-proxy — OpenAI-compatible API in front of cursor-agent CLI (ACP).
#
# Every request uses cursor-agent ACP (`agent acp`):
# - session/new with in-process MCP server for client tools[]
# - session/prompt with text + image ContentBlocks
# - session/request_permission denies built-in tools, allows MCP client tools
# - Tool invocations park until HTTP client returns role:"tool"
#
# Requires CURSOR_API_KEY (Cursor subscription) at runtime.
FROM ubuntu:noble
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Node 20 (LTS) — proxy and stdio MCP bridge both run on Node.
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# cursor-agent CLI (subscription-backed).
RUN curl -fsS https://cursor.com/install | bash && \
CURSOR_VERSION_DIR="$(echo /root/.local/share/cursor-agent/versions/*)" && \
cp -r "${CURSOR_VERSION_DIR}" /opt/cursor-agent && \
ln -sf /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent && \
ln -sf /opt/cursor-agent/cursor-agent /usr/local/bin/agent.real
# Application code.
COPY src/ /app/src/
COPY scripts/ /app/scripts/
COPY cursor-bootstrap/ /opt/cursor-bootstrap/
COPY entrypoint.sh /app/entrypoint.sh
RUN mkdir -p /workspace /home/nobody/.cursor && \
chmod +x /app/scripts/*.sh /app/entrypoint.sh && \
chown -R nobody:nogroup /opt/cursor-agent /opt/cursor-bootstrap /app /workspace /home/nobody
WORKDIR /workspace
ENV PORT=4646
ENV HOME=/home/nobody
ENV NODE_ENV=production
USER nobody
EXPOSE 4646
ENTRYPOINT ["/app/entrypoint.sh"]
+46
View File
@@ -0,0 +1,46 @@
# cursor-agent-api-proxy
OpenAI-compatible HTTP API wrapping `cursor-agent` via **ACP** (`agent acp`). Deployed on **node4-k3s**.
- **Image**: `dcr.trinitysan.by/infra/cursor-agent-api-proxy`
- **Deploy**: `deploy/` (PVC, Deployment, Service, ServiceExport)
- **Flux image automation**: `flux/` (ImageRepository / ImagePolicy / ImageUpdateAutomation)
Infra on node4 keeps thin Flux glue (Namespace, SecretAdapters, `GitRepository`, Kustomizations) in `infrastructure/node4-k3s/131-cursor-agent-api.yaml`. Public HTTPS + Authentik live on node2 (`245-authentik-cursor-agent-api`).
## Access
| Endpoint | Detail |
|----------|--------|
| Public | `https://cursor-api.trinitysan.by` (Authentik outpost on node2) |
| In-cluster / MCS | `cursor-agent-api.cursor-agent-api.svc.clusterset.local:4646` |
| Health | `GET /health` |
## CI
Gitea Actions (`.gitea/workflows/build-and-push.yaml`) on `master` and `workflow_dispatch` pushes:
| Tag | Purpose |
|-----|---------|
| `:latest` | Convenience |
| `:<git-sha>` | Immutable full SHA |
| `:<run_number>-<git-sha>` | Sortable; elected by Flux ImagePolicy |
Org secrets: `DCR_USERNAME`, `DCR_PASSWORD` (infra org).
## Local build / E2E
```bash
docker build -t cursor-agent-api-proxy:latest .
# Requires CURSOR_API_KEY in env or .env
./scripts/e2e-docker.sh
```
## Flux update flow
1. CI pushes `run_number-sha` to DCR
2. ImageRepository scans; ImagePolicy picks highest `run_number`
3. ImageUpdateAutomation commits the new tag into `deploy/010-cursor-agent-api.yaml`
4. Kustomization applies; Deployment rolls
Requires a Gitea PAT with **read+write** on this repo (`GITEA_CURSOR_AGENT_API_GITOPS_TOKEN` on node4, see infra `060-external-secrets.yaml.template`).
+26
View File
@@ -0,0 +1,26 @@
{
"version": 1,
"editor": { "vimMode": false },
"permissions": {
"allow": ["Mcp(*)"],
"deny": ["Shell(*)", "Read(*)", "Write(*)", "Grep(*)", "Glob(*)", "WebFetch(*)"]
},
"model": {
"modelId": "default",
"displayModelId": "auto",
"displayName": "Auto",
"displayNameShort": "Auto",
"aliases": ["auto"],
"maxMode": false
},
"hasChangedDefaultModel": true,
"approvalMode": "allowlist",
"sandbox": {
"mode": "disabled",
"networkAccess": "user_config_with_defaults"
},
"attribution": {
"attributeCommitsToAgent": false,
"attributePRsToAgent": false
}
}
+153
View File
@@ -0,0 +1,153 @@
# Cursor Agent API workload (node4).
# Namespace + SecretAdapters live in infra glue (131-cursor-agent-api.yaml).
# Image tag updated by Flux ImageUpdateAutomation (run_number-sha tags).
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cursor-agent-state
namespace: cursor-agent-api
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-storage
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cursor-agent-api
namespace: cursor-agent-api
labels:
app: cursor-agent-api
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: cursor-agent-api
template:
metadata:
labels:
app: cursor-agent-api
spec:
imagePullSecrets:
- name: dcr-registry-auth-ro
initContainers:
- name: bootstrap-cursor
image: dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest # {"$imagepolicy": "flux-system:cursor-agent-api-proxy"}
imagePullPolicy: IfNotPresent
env:
- name: CURSOR_API_KEY
valueFrom:
secretKeyRef:
name: cursor-auth
key: CURSOR_API_KEY
command:
- /app/scripts/bootstrap-cursor.sh
volumeMounts:
- name: cursor-state
mountPath: /home/nobody/.cursor
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: proxy
image: dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest # {"$imagepolicy": "flux-system:cursor-agent-api-proxy"}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 4646
env:
- name: CURSOR_API_KEY
valueFrom:
secretKeyRef:
name: cursor-auth
key: CURSOR_API_KEY
- name: PORT
value: "4646"
- name: PROXY_SESSION_TTL_MS
value: "120000"
- name: PROXY_MAX_CONCURRENT_SESSIONS
value: "4"
- name: ACP_REQUEST_TIMEOUT_MS
value: "300000"
- name: PROXY_INTERNAL_TOKEN
valueFrom:
secretKeyRef:
name: cursor-proxy-internal-token
key: PROXY_INTERNAL_TOKEN
volumeMounts:
- name: cursor-state
mountPath: /home/nobody/.cursor
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
volumes:
- name: cursor-state
persistentVolumeClaim:
claimName: cursor-agent-state
---
apiVersion: v1
kind: Service
metadata:
name: cursor-agent-api
namespace: cursor-agent-api
annotations:
service.kubernetes.io/topology-mode: Auto
spec:
selector:
app: cursor-agent-api
ports:
- name: http
port: 4646
targetPort: http
---
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceExport
metadata:
name: cursor-agent-api
namespace: cursor-agent-api
+26
View File
@@ -0,0 +1,26 @@
# cursor-agent bootstrap — Desired State
## Purpose
Prepare the **cursor-agent-api-proxy** container image with a working Cursor CLI login and persistent `~/.cursor` state before the proxy serves traffic.
## Mechanism
1. **Image** — Ubuntu + Node 20 + cursor-agent CLI (`agent.real`) + the OpenAI-compatible ACP proxy (`src/server.mjs`).
2. **Bootstrap**`bootstrap-cursor.sh` runs in an init container (and entrypoint) with `CURSOR_API_KEY` from SecretAdapter `cursor-auth` to seed `/home/nobody/.cursor` on the PVC.
3. **Runtime** — Each `/v1/chat/completions` request spawns `agent acp` with in-process MCP for client tools and native image ContentBlocks.
The legacy GLM/stream-json wrapper path is **not** used in this image.
## Build-time
- Install cursor-agent CLI and proxy sources in the Docker image (no secrets at build time).
## Runtime
- Init container + PVC: `bootstrap-cursor.sh` with `CURSOR_API_KEY` from `cursor-auth`.
- Proxy env: `PROXY_INTERNAL_TOKEN`, `PROXY_MAX_CONCURRENT_SESSIONS`, `PROXY_SESSION_TTL_MS`, `ACP_REQUEST_TIMEOUT_MS`.
## Related
- [131-cursor-agent-api-goal.md](../../infrastructure/node4-k3s/131-cursor-agent-api-goal.md)
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Container entrypoint: run cursor-agent bootstrap, then start the proxy.
set -euo pipefail
# Seed ~/.cursor/cli-config.json (default global permissions) and verify
# the cursor-agent binary responds.
/app/scripts/bootstrap-cursor.sh
# The HTTP server. Sessions spawn cursor-agent ACP subprocesses per request.
exec node /app/src/server.mjs
+59
View File
@@ -0,0 +1,59 @@
# Flux image automation for cursor-agent-api-proxy (applied into flux-system on node4).
# GitRepository cursor-agent-api-proxy is defined in infra glue (131-cursor-agent-api.yaml).
# CI tags: latest, <sha>, <run_number>-<sha> — policy elects the last form.
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: cursor-agent-api-proxy
namespace: flux-system
spec:
image: dcr.trinitysan.by/infra/cursor-agent-api-proxy
interval: 1m0s
secretRef:
name: dcr-registry-auth-ro
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: cursor-agent-api-proxy
namespace: flux-system
spec:
imageRepositoryRef:
name: cursor-agent-api-proxy
filterTags:
pattern: '^(?P<n>[0-9]+)-[0-9a-f]{40}$'
extract: '$n'
policy:
numerical:
order: desc
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: cursor-agent-api-proxy
namespace: flux-system
spec:
interval: 5m0s
sourceRef:
kind: GitRepository
name: cursor-agent-api-proxy
git:
checkout:
ref:
branch: master
commit:
author:
email: fluxcdbot@users.noreply.gitea.trinitysan.by
name: fluxcdbot
messageTemplate: |
chore(deploy): update cursor-agent-api-proxy image
{{range .Changed.Changes}}{{print .OldValue}} -> {{println .NewValue}}{{end}}
push:
branch: master
update:
path: ./deploy
strategy: Setters
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Bootstrap: seed ~/.cursor/cli-config.json and verify the cursor-agent
# binary works before the HTTP server starts serving traffic.
set -euo pipefail
CURSOR_HOME="${HOME}/.cursor"
BOOTSTRAP="/opt/cursor-bootstrap"
export NO_OPEN_BROWSER=1
mkdir -p "${CURSOR_HOME}"
if [[ -f "${BOOTSTRAP}/cli-config.json" ]] && [[ ! -f "${CURSOR_HOME}/cli-config.json" ]]; then
cp -f "${BOOTSTRAP}/cli-config.json" "${CURSOR_HOME}/cli-config.json"
fi
# cursor-agent auth via env (no interactive login).
if [[ -z "${CURSOR_API_KEY:-}" ]]; then
echo "CURSOR_API_KEY is not set" >&2
exit 1
fi
export CURSOR_API_KEY
# cursor-agent itself must respond.
if ! /usr/local/bin/agent.real --version >/dev/null 2>&1; then
echo "cursor-agent binary is not executable" >&2
exit 1
fi
echo "cursor-agent bootstrap: auth ok, agent responds"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Local E2E: build image, run container with .env secrets, smoke-test text + image.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
IMAGE="${E2E_IMAGE:-cursor-agent-api-proxy:latest}"
PORT="${E2E_PORT:-18766}"
CONTAINER="${E2E_CONTAINER:-cursor-agent-api-e2e}"
TIMEOUT="${E2E_TIMEOUT:-180}"
ENV_FILE="${REPO_ROOT}/.env"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: .env not found at $ENV_FILE" >&2
exit 1
fi
cleanup() {
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Building $IMAGE"
docker build -t "$IMAGE" "$ROOT"
cleanup
echo "==> Starting container (host network, PORT=$PORT)"
docker run -d --name "$CONTAINER" --network host --env-file "$ENV_FILE" -e "PORT=$PORT" "$IMAGE" >/dev/null
echo "==> Waiting for /health"
for i in $(seq 1 60); do
if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
break
fi
if [ "$i" -eq 60 ]; then
echo "Health check failed" >&2
docker logs "$CONTAINER" 2>&1 | tail -30
exit 1
fi
sleep 1
done
echo "==> Plain text chat"
AUTH_ARGS=()
if [ -n "${PROXY_INTERNAL_TOKEN:-}" ]; then
AUTH_ARGS=(-H "Authorization: Bearer ${PROXY_INTERNAL_TOKEN}")
fi
TEXT_RESP="$(curl -sf --max-time "$TIMEOUT" "http://127.0.0.1:${PORT}/v1/chat/completions" \
"${AUTH_ARGS[@]}" \
-H "Content-Type: application/json" \
-d '{"model":"auto","stream":false,"messages":[{"role":"user","content":"Reply with exactly: HELLO"}]}')"
echo "$TEXT_RESP" | grep -q '"content":"HELLO"' || {
echo "Plain chat failed:" >&2
echo "$TEXT_RESP" >&2
exit 1
}
echo "OK: plain chat"
PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
echo "==> Image chat (data: PNG)"
IMG_RESP="$(curl -sf --max-time "$TIMEOUT" "http://127.0.0.1:${PORT}/v1/chat/completions" \
"${AUTH_ARGS[@]}" \
-H "Content-Type: application/json" \
-d "{\"model\":\"auto\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What color is this 1x1 pixel? Reply with one color word only.\"},{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,${PNG_B64}\"}}]}]}")"
echo "$IMG_RESP" | grep -q '"finish_reason":"stop"' || {
echo "Image chat failed:" >&2
echo "$IMG_RESP" >&2
docker logs "$CONTAINER" 2>&1 | tail -40
exit 1
}
CONTENT="$(echo "$IMG_RESP" | sed -n 's/.*"content":"\([^"]*\)".*/\1/p' | head -1)"
if [ -z "$CONTENT" ]; then
echo "Image chat returned empty content:" >&2
echo "$IMG_RESP" >&2
exit 1
fi
echo "OK: image chat → $CONTENT"
echo "==> All E2E checks passed"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Bridge: cursor-agent spawns this as a stdio MCP server. We connect to a
# per-session unix socket provided as $1 and tunnel newline-delimited
# JSON-RPC frames between cursor-agent's stdin/stdout and the proxy-owned
# socket server (which is the real MCP server).
#
# Uses Node (always available in the image) rather than nc, to avoid
# depending on netcat variants that may not support -U for unix sockets.
socket="$1"
if [ -z "$socket" ]; then
echo "usage: $0 <socket-path>" >&2
exit 1
fi
exec node -e '
const sock = process.argv[1];
if (!sock) { console.error("mcp-stdio-bridge: missing socket arg"); process.exit(1); }
const net = require("node:net");
const conn = net.createConnection(sock);
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
// Forward as-is; JSON-RPC frames are newline-delimited on both sides.
conn.write(chunk);
});
process.stdin.on("end", () => conn.end());
conn.setEncoding("utf8");
conn.on("data", (chunk) => process.stdout.write(chunk));
conn.on("end", () => process.exit(0));
conn.on("close", () => process.exit(0));
conn.on("error", (e) => {
console.error("[mcp-stdio-bridge] socket error: " + e.message);
process.exit(1);
});
process.on("SIGPIPE", () => process.exit(0));
' "$socket"
+164
View File
@@ -0,0 +1,164 @@
/**
* Minimal ACP (Agent Client Protocol) JSON-RPC client over stdio.
* Newline-delimited JSON-RPC 2.0 per ACP transport spec.
*/
export class AcpClient {
/**
* @param {object} opts
* @param {import("node:stream").Writable} opts.stdin
* @param {import("node:stream").Readable} opts.stdout
* @param {(msg: string) => void} [opts.log]
*/
constructor({ stdin, stdout, log, requestTimeoutMs }) {
this.stdin = stdin;
this.stdout = stdout;
this.log = log || (() => {});
this.requestTimeoutMs =
requestTimeoutMs ?? parseInt(process.env.ACP_REQUEST_TIMEOUT_MS || "300000", 10);
this._buf = "";
this._nextId = 1;
/** @type {Map<number, {resolve: Function, reject: Function}>} */
this._pending = new Map();
/** @type {(update: object) => void} */
this.onSessionUpdate = null;
/**
* Handle incoming JSON-RPC requests from the agent (permissions, cursor extensions).
* @type {(method: string, params: object) => Promise<object>}
*/
this.onIncomingRequest = null;
this._closed = false;
this.agentCapabilities = null;
stdout.setEncoding("utf8");
stdout.on("data", (chunk) => this._onData(chunk));
}
_send(obj) {
if (this._closed || this.stdin.destroyed) return;
this.stdin.write(`${JSON.stringify(obj)}\n`);
}
_onData(chunk) {
this._buf += chunk;
let nl;
while ((nl = this._buf.indexOf("\n")) >= 0) {
const line = this._buf.slice(0, nl).trim();
this._buf = this._buf.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
this.log(`[acp] parse error: ${e.message}`);
continue;
}
this._dispatch(msg).catch((e) => this.log(`[acp] dispatch error: ${e.message}`));
}
}
async _dispatch(msg) {
if (msg.method === "session/update") {
this.onSessionUpdate?.(msg.params || {});
return;
}
if (msg.method && msg.id != null && !Object.prototype.hasOwnProperty.call(msg, "result") && !msg.error) {
const handler = this.onIncomingRequest;
if (!handler) {
this._send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32603, message: "no incoming request handler" },
});
return;
}
try {
const result = await handler(msg.method, msg.params || {});
this._send({ jsonrpc: "2.0", id: msg.id, result: result ?? {} });
} catch (e) {
this._send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32000, message: e.message || "handler failed" },
});
}
return;
}
if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
const p = this._pending.get(msg.id);
if (!p) return;
this._pending.delete(msg.id);
if (msg.error) {
const err = new Error(msg.error.message || "ACP error");
err.code = msg.error.code;
err.data = msg.error.data;
p.reject(err);
} else {
p.resolve(msg.result);
}
}
}
/**
* @param {string} method
* @param {object} [params]
* @returns {Promise<object>}
*/
request(method, params = {}) {
const id = this._nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (!this._pending.has(id)) return;
this._pending.delete(id);
reject(new Error(`ACP request timeout after ${this.requestTimeoutMs}ms: ${method}`));
}, this.requestTimeoutMs);
this._pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
this._send({ jsonrpc: "2.0", id, method, params });
});
}
async initialize() {
const result = await this.request("initialize", {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
},
clientInfo: { name: "cursor-agent-api-proxy", version: "1.6.0" },
});
this.agentCapabilities = result.agentCapabilities || null;
if (!result.agentCapabilities?.promptCapabilities?.image) {
throw new Error("cursor-agent ACP does not advertise image prompt capability");
}
return result;
}
async authenticateIfNeeded() {
try {
return await this.request("authenticate", { methodId: "cursor_login" });
} catch (e) {
this.log(`[acp] authenticate skipped or failed: ${e.message}`);
return null;
}
}
close() {
this._closed = true;
for (const [, p] of this._pending) {
p.reject(new Error("ACP client closed"));
}
this._pending.clear();
}
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import { AcpClient } from "./acp-client.mjs";
describe("AcpClient", () => {
it("resolves request/response pairs", async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const client = new AcpClient({ stdin, stdout });
const p = client.request("ping", { ok: true });
const written = await new Promise((r) => {
stdin.once("data", (c) => r(JSON.parse(c.toString())));
});
assert.equal(written.method, "ping");
assert.equal(written.id, 1);
stdout.write(
`${JSON.stringify({ jsonrpc: "2.0", id: written.id, result: { pong: true } })}\n`
);
const result = await p;
assert.deepEqual(result, { pong: true });
client.close();
});
it("dispatches session/update notifications", async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const client = new AcpClient({ stdin, stdout });
const updates = [];
client.onSessionUpdate = (u) => updates.push(u);
stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
method: "session/update",
params: { sessionId: "s1", update: { sessionUpdate: "agent_message_chunk" } },
})}\n`
);
await new Promise((r) => setTimeout(r, 50));
assert.equal(updates.length, 1);
assert.equal(updates[0].sessionId, "s1");
client.close();
});
it("rejects requests that exceed timeout", async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const client = new AcpClient({ stdin, stdout, requestTimeoutMs: 30 });
const p = client.request("slow", {});
await assert.rejects(p, /timeout.*slow/i);
client.close();
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* In-process MCP server speaking JSON-RPC 2.0 (newline-delimited) over a
* unix socket. cursor-agent connects to this socket via a tiny stdio bridge
* script (scripts/mcp-stdio-bridge.sh) and treats it as a regular stdio MCP
* server.
*
* The server ADVERTISES the client's tools (so the LLM sees them and can
* decide to call them) but does NOT execute them. When `tools/call` arrives,
* it generates an OpenAI-style `tool_call_id`, parks the response as a
* pending Promise, and emits a `tool_call` event on the parent session.
* The proxy resolves the Promise later when the HTTP client sends back a
* `role: "tool"` message with the result.
*/
import { createServer } from "node:net";
/**
* @typedef {Object} McpTool
* @property {string} name
* @property {string} [description]
* @property {object} [inputSchema] JSON Schema for parameters
*/
/**
* @typedef {Object} PendingToolCall
* @property {string} toolCallId
* @property {string} name
* @property {object} arguments
* @property {(result: string) => void} resolve
* @property {(err: Error) => void} reject
*/
export class ProxyMcpServer {
/**
* @param {object} opts
* @param {string} opts.socketPath
* @param {McpTool[]} opts.tools
* @param {(call: {toolCallId: string, name: string, arguments: object}) => void} opts.onToolCall
* @param {(msg: string) => void} [opts.log]
*/
constructor({ socketPath, tools, onToolCall, log }) {
this.socketPath = socketPath;
this.tools = tools;
this.onToolCall = onToolCall || (() => {});
this.log = log || (() => {});
/** @type {import("node:net").Server} */
this.server = null;
/** Connection that cursor-agent is currently using. */
this.conn = null;
/** @type {Map<string, PendingToolCall>} key = toolCallId */
this.pending = new Map();
}
/** Start listening on the unix socket. */
async start() {
this.server = createServer((conn) => this._handleConn(conn));
await new Promise((resolve, reject) => {
this.server.once("error", reject);
this.server.listen(this.socketPath, resolve);
});
}
async stop() {
// Reject any pending tool calls so callers don't hang.
for (const [, p] of this.pending) {
p.reject(new Error("mcp server stopped"));
}
this.pending.clear();
if (this.conn) {
try {
this.conn.end();
} catch {
/* ignore */
}
}
if (this.server) {
await new Promise((r) => this.server.close(r));
}
}
/**
* Resolve a parked tool call with a result string. Returns true if a
* matching pending call was found.
*/
resolveToolCall(toolCallId, result) {
const p = this.pending.get(toolCallId);
if (!p) return false;
this.pending.delete(toolCallId);
p.resolve(String(result ?? ""));
return true;
}
/** Reject a parked tool call (e.g. on session teardown). */
rejectToolCall(toolCallId, err) {
const p = this.pending.get(toolCallId);
if (!p) return false;
this.pending.delete(toolCallId);
p.reject(err);
return true;
}
// --------------------------------------------------------------------------
_handleConn(conn) {
// cursor-agent spawns one stdio bridge per session; only one connection
// is expected. If a new one arrives, drop the old.
if (this.conn && this.conn.writable) {
this.log("mcp: new connection replacing existing");
try {
this.conn.end();
} catch {
/* ignore */
}
}
this.conn = conn;
conn.setEncoding("utf8");
let buf = "";
conn.on("data", (chunk) => {
buf += chunk;
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const trimmed = line.trim();
if (!trimmed) continue;
let msg;
try {
msg = JSON.parse(trimmed);
} catch (e) {
this.log(`mcp: parse error: ${e.message}`);
continue;
}
this._handleMessage(msg, conn).catch((e) => this.log(`mcp: handler error: ${e.message}`));
}
});
conn.on("error", (e) => this.log(`mcp: conn error: ${e.message}`));
conn.on("close", () => {
if (this.conn === conn) this.conn = null;
});
}
_send(conn, obj) {
if (conn.writableEnded) return;
conn.write(JSON.stringify(obj) + "\n");
}
async _handleMessage(msg, conn) {
const { id, method, params } = msg;
if (method === "initialize") {
this._send(conn, {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "proxy-client-tools", version: "1.0.0" },
},
});
return;
}
if (method === "notifications/initialized") {
// No response required for notifications.
return;
}
if (method === "tools/list") {
const tools = this.tools.map((t) => ({
name: t.name,
description: t.description || `Client-side tool ${t.name}`,
inputSchema: t.inputSchema || { type: "object", properties: {} },
}));
this._send(conn, { jsonrpc: "2.0", id, result: { tools } });
return;
}
if (method === "tools/call") {
this._handleToolsCall(id, params || {}, conn);
return;
}
this._send(conn, {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `unknown method: ${method}` },
});
}
_handleToolsCall(id, params, conn) {
const { name, arguments: args } = params;
const toolCallId =
params.toolCallId ||
`call_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
/** @type {PendingToolCall} */
const pending = {
toolCallId,
name,
arguments: args || {},
resolve: (result) => {
this._send(conn, {
jsonrpc: "2.0",
id,
result: { content: [{ type: "text", text: String(result ?? "") }] },
});
},
reject: (err) => {
this._send(conn, {
jsonrpc: "2.0",
id,
error: { code: -32000, message: err.message || "tool call failed" },
});
},
};
this.pending.set(toolCallId, pending);
// Notify session so it can stream the OpenAI tool_calls chunk to the
// HTTP client. The session stays alive while we hold this Promise.
this.onToolCall({ toolCallId, name, arguments: args || {} });
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Convert OpenAI chat messages to ACP ContentBlock arrays.
*/
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
export const MAX_IMAGES_PER_REQUEST = 5;
export const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
const ALLOWED_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const PRIVATE_IP_PATTERNS = [
/^127\./,
/^10\./,
/^192\.168\./,
/^169\.254\./,
/^0\./,
/^::1$/,
/^fc00:/i,
/^fd00:/i,
/^fe80:/i,
];
export function messageContentToText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function hasImageParts(messages) {
for (const m of messages || []) {
if (!Array.isArray(m.content)) continue;
if (m.content.some((p) => p.type === "image_url")) return true;
}
return false;
}
function countImages(messages) {
let n = 0;
for (const m of messages || []) {
if (!Array.isArray(m.content)) continue;
n += m.content.filter((p) => p.type === "image_url").length;
}
return n;
}
function parseDataUrl(url) {
const m = url.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
if (!m) throw new Error("invalid data:image URL (expected base64 image)");
const mimeType = m[1].toLowerCase();
if (!ALLOWED_MIMES.has(mimeType)) {
throw new Error(`unsupported image MIME type: ${mimeType}`);
}
const data = m[2];
const buf = Buffer.from(data, "base64");
if (buf.length > MAX_IMAGE_BYTES) {
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
}
return { mimeType, data: buf.toString("base64") };
}
/** Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) to dotted IPv4 for range checks. */
export function normalizeIpAddress(ip) {
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (mapped) return mapped[1];
return ip;
}
export function isPrivateIp(ip) {
const normalized = normalizeIpAddress(ip);
if (isIP(normalized) === 4) {
if (normalized.startsWith("172.")) {
const second = parseInt(normalized.split(".")[1], 10);
if (second >= 16 && second <= 31) return true;
}
return PRIVATE_IP_PATTERNS.some((re) => re.test(normalized));
}
if (isIP(normalized) === 6) {
return PRIVATE_IP_PATTERNS.some((re) => re.test(normalized));
}
return false;
}
async function assertPublicHost(hostname) {
if (hostname === "localhost" || hostname.endsWith(".local")) {
throw new Error("image URL host not allowed");
}
const records = await lookup(hostname, { all: true });
for (const r of records) {
if (isPrivateIp(r.address)) {
throw new Error("image URL resolves to private IP address");
}
}
}
async function fetchHttpsImage(url) {
let current = url;
for (let redirects = 0; redirects <= 3; redirects++) {
const parsed = new URL(current);
if (parsed.protocol !== "https:") {
throw new Error("image URL must use https");
}
await assertPublicHost(parsed.hostname);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
let res;
try {
res = await fetch(current, { signal: controller.signal, redirect: "manual" });
} finally {
clearTimeout(timer);
}
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location");
if (!loc) throw new Error("image URL redirect missing Location header");
current = new URL(loc, current).toString();
continue;
}
if (!res.ok) {
throw new Error(`failed to fetch image URL: HTTP ${res.status}`);
}
const contentType = (res.headers.get("content-type") || "image/jpeg").split(";")[0].trim().toLowerCase();
if (!ALLOWED_MIMES.has(contentType)) {
throw new Error(`unsupported image MIME type from URL: ${contentType}`);
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > MAX_IMAGE_BYTES) {
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
}
return { mimeType: contentType, data: buf.toString("base64") };
}
throw new Error("too many redirects fetching image URL");
}
/**
* @param {string} url
* @returns {Promise<{mimeType: string, data: string}>}
*/
export async function parseImageUrl(url) {
if (typeof url !== "string" || !url) {
throw new Error("image_url.url is required");
}
if (url.startsWith("data:")) {
return parseDataUrl(url);
}
if (url.startsWith("https://")) {
return fetchHttpsImage(url);
}
throw new Error("image_url must be a data: URL or https:// URL");
}
function roleLabel(role) {
if (role === "system") return "System";
if (role === "user") return "User";
if (role === "assistant") return "Assistant";
return role;
}
function formatMessageText(m) {
const text = messageContentToText(m.content);
let body = text;
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
const calls = m.tool_calls
.map((c) => ` - ${c.function?.name}(${c.function?.arguments || "{}"}) [id=${c.id}]`)
.join("\n");
body = `${text}\n[Tool calls issued:]\n${calls}`;
}
return body;
}
/**
* @param {Array<object>} messages OpenAI messages
* @returns {Promise<Array<object>>} ACP ContentBlock[]
*/
export async function openAiMessagesToAcpBlocks(messages) {
const imageCount = countImages(messages);
if (imageCount > MAX_IMAGES_PER_REQUEST) {
throw new Error(`too many images (max ${MAX_IMAGES_PER_REQUEST})`);
}
const filtered = (messages || []).filter((m) => m.role !== "tool");
const nonEmpty = filtered.filter((m) => {
if (typeof m.content === "string") return m.content.length > 0;
if (Array.isArray(m.content)) {
return m.content.some((p) => p.type === "text" && p.text) || m.content.some((p) => p.type === "image_url");
}
if (m.tool_calls) return true;
return false;
});
/** @type {Array<object>} */
const blocks = [];
for (const m of nonEmpty) {
const label = roleLabel(m.role);
const body = formatMessageText(m);
if (body) {
blocks.push({ type: "text", text: `[${label}]\n${body}` });
} else if (Array.isArray(m.content) && m.content.some((p) => p.type === "image_url")) {
blocks.push({ type: "text", text: `[${label}]` });
}
if (Array.isArray(m.content)) {
for (const part of m.content) {
if (part.type !== "image_url") continue;
const url = part.image_url?.url;
const { mimeType, data } = await parseImageUrl(url);
blocks.push({ type: "image", mimeType, data });
}
}
}
if (blocks.length === 0) {
throw new Error("no content blocks produced from messages");
}
return blocks;
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
messageContentToText,
hasImageParts,
parseImageUrl,
openAiMessagesToAcpBlocks,
MAX_IMAGES_PER_REQUEST,
} from "./message-converter.mjs";
import { pickModelValueId, resolveModelId } from "./session-manager.mjs";
const PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
describe("messageContentToText", () => {
it("joins text parts", () => {
assert.equal(
messageContentToText([{ type: "text", text: "a" }, { type: "text", text: "b" }]),
"ab"
);
});
it("ignores image_url parts", () => {
assert.equal(
messageContentToText([
{ type: "text", text: "hi" },
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
]),
"hi"
);
});
});
describe("hasImageParts", () => {
it("detects image_url", () => {
assert.equal(
hasImageParts([{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }]),
true
);
assert.equal(hasImageParts([{ role: "user", content: "text only" }]), false);
});
});
describe("parseImageUrl", () => {
it("parses data URL", async () => {
const r = await parseImageUrl(`data:image/png;base64,${PNG_B64}`);
assert.equal(r.mimeType, "image/png");
assert.equal(r.data, PNG_B64);
});
it("rejects http URL", async () => {
await assert.rejects(() => parseImageUrl("http://example.com/x.png"), /https/);
});
it("rejects private IP URL", async () => {
await assert.rejects(
() => parseImageUrl("https://127.0.0.1/x.png"),
/private|not allowed/i
);
});
});
describe("isPrivateIp", () => {
it("treats IPv4-mapped loopback as private", async () => {
const { isPrivateIp } = await import("./message-converter.mjs");
assert.equal(isPrivateIp("::ffff:127.0.0.1"), true);
assert.equal(isPrivateIp("::ffff:10.0.0.1"), true);
assert.equal(isPrivateIp("8.8.8.8"), false);
});
});
describe("openAiMessagesToAcpBlocks", () => {
it("produces labeled text blocks", async () => {
const blocks = await openAiMessagesToAcpBlocks([{ role: "user", content: "Hello" }]);
assert.equal(blocks.length, 1);
assert.equal(blocks[0].type, "text");
assert.match(blocks[0].text, /\[User\]/);
assert.match(blocks[0].text, /Hello/);
});
it("adds image block after message text", async () => {
const blocks = await openAiMessagesToAcpBlocks([
{
role: "user",
content: [
{ type: "text", text: "What color?" },
{ type: "image_url", image_url: { url: `data:image/png;base64,${PNG_B64}` } },
],
},
]);
assert.equal(blocks.length, 2);
assert.equal(blocks[0].type, "text");
assert.equal(blocks[1].type, "image");
assert.equal(blocks[1].mimeType, "image/png");
});
it("rejects too many images", async () => {
const parts = Array.from({ length: MAX_IMAGES_PER_REQUEST + 1 }, () => ({
type: "image_url",
image_url: { url: `data:image/png;base64,${PNG_B64}` },
}));
await assert.rejects(
() => openAiMessagesToAcpBlocks([{ role: "user", content: parts }]),
/too many images/
);
});
});
describe("pickModelValueId", () => {
const modelOption = {
id: "model",
currentValue: "default[]",
options: [
{ value: "default[]", name: "Auto" },
{ value: "gpt-5.2", name: "GPT-5.2" },
{ value: "composer-2.5[fast=true]", name: "composer-2.5" },
],
};
it("maps auto to default[]", () => {
assert.equal(pickModelValueId(modelOption, "auto"), "default[]");
});
it("maps exact model id", () => {
assert.equal(pickModelValueId(modelOption, "gpt-5.2"), "gpt-5.2");
});
it("maps by prefix bracket", () => {
assert.equal(pickModelValueId(modelOption, "composer-2.5"), "composer-2.5[fast=true]");
});
});
describe("resolveModelId", () => {
const sessionWithModels = {
models: {
currentModelId: "default[]",
availableModels: [
{ modelId: "default[]", name: "Auto" },
{ modelId: "gpt-5.2", name: "GPT-5.2" },
{ modelId: "composer-2.5[fast=true]", name: "composer-2.5" },
],
},
configOptions: [],
};
it("maps auto via availableModels", () => {
assert.equal(resolveModelId(sessionWithModels, "auto"), "default[]");
});
it("maps exact modelId in availableModels", () => {
assert.equal(resolveModelId(sessionWithModels, "gpt-5.2"), "gpt-5.2");
});
it("maps by name in availableModels", () => {
assert.equal(resolveModelId(sessionWithModels, "composer-2.5"), "composer-2.5[fast=true]");
});
it("falls back to configOptions when availableModels empty", () => {
const sessionResult = {
models: { currentModelId: null, availableModels: [] },
configOptions: [
{
id: "model",
currentValue: "default[]",
options: [{ value: "default[]", name: "Auto" }, { value: "gpt-5.2", name: "GPT-5.2" }],
},
],
};
assert.equal(resolveModelId(sessionResult, "gpt-5.2"), "gpt-5.2");
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Model registry. The proxy exposes all Cursor-subscription models, served
* via the cursor-agent CLI. There is no separate provider path.
*
* The list is served on GET /v1/models.
*/
export const CURSOR_MODELS = [
"auto",
"composer-1.5",
"composer-1",
"opus-4.6-thinking",
"opus-4.6",
"opus-4.5-thinking",
"opus-4.5",
"sonnet-4.5-thinking",
"sonnet-4.5",
"gpt-5.3-codex",
"gpt-5.3-codex-fast",
"gpt-5.3-codex-low",
"gpt-5.3-codex-low-fast",
"gpt-5.3-codex-high",
"gpt-5.3-codex-high-fast",
"gpt-5.3-codex-xhigh",
"gpt-5.3-codex-xhigh-fast",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.2-codex-low",
"gpt-5.2-codex-low-fast",
"gpt-5.1-codex-max",
"gemini-3-pro",
"gemini-3-flash",
"grok",
];
export function allModelIds() {
return [...CURSOR_MODELS];
}
/** Normalize a model id from the request to a known value or "auto". */
export function normalizeModel(input) {
if (!input) return "auto";
const m = String(input);
if (m.startsWith("cursor/")) return m.slice("cursor/".length) || "auto";
if (m.startsWith("cursor-")) {
const r = m.slice("cursor-".length);
if (r && CURSOR_MODELS.includes(r)) return r;
}
if (CURSOR_MODELS.includes(m)) return m;
return "auto";
}
+405
View File
@@ -0,0 +1,405 @@
/**
* OpenAI-compatible proxy in front of cursor-agent CLI (ACP transport).
*
* Endpoints:
* GET /v1/models List supported models
* POST /v1/chat/completions Chat completion (streaming or not)
* GET /health Liveness probe
*
* Every request uses cursor-agent ACP (`agent acp`):
* - session/new with in-process MCP server for client tools[]
* - session/prompt with text + image ContentBlocks
* - session/request_permission denies built-in tools, allows MCP client tools
* - Tool invocations park until HTTP client returns role:"tool"
* - In-cluster callers authenticate with PROXY_INTERNAL_TOKEN; Authentik outpost via X-Authentik-Username
*
* No external dependencies. Uses only Node built-ins.
*/
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { allModelIds, normalizeModel } from "./models.mjs";
import {
createSession,
findSessionForFollowUp,
destroyAllSessions,
} from "./session-manager.mjs";
import {
messageContentToText,
hasImageParts,
openAiMessagesToAcpBlocks,
} from "./message-converter.mjs";
import { textChunk, toolCallChunk, finishChunk, nonStreamResponse } from "./stream-translator.mjs";
const PORT = parseInt(process.env.PORT || "4646", 10);
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "10000", 10);
const MAX_BODY_BYTES = parseInt(process.env.PROXY_MAX_BODY_BYTES || String(24 * 1024 * 1024), 10);
const INTERNAL_TOKEN = process.env.PROXY_INTERNAL_TOKEN || "";
/** @param {import("node:http").ServerResponse} res */
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.setHeader("Content-Length", Buffer.byteLength(body));
res.end(body);
}
function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size > maxBytes) {
reject(new Error(`request body exceeds ${maxBytes} bytes`));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const buf = Buffer.concat(chunks);
if (buf.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(buf.toString("utf8")));
} catch (e) {
reject(new Error(`invalid JSON body: ${e.message}`));
}
});
req.on("error", reject);
});
}
function extractBearerToken(req) {
const auth = req.headers.authorization || "";
if (auth.startsWith("Bearer ")) {
return auth.slice(7).trim();
}
return "";
}
function isPublicPath(pathname) {
return pathname === "/health" || pathname === "/";
}
/**
* When PROXY_INTERNAL_TOKEN is set, require it for API routes unless the
* request was already authenticated by an Authentik proxy outpost.
*/
function isAuthorized(req) {
if (!INTERNAL_TOKEN) return true;
const url = new URL(req.url, "http://internal");
if (isPublicPath(url.pathname)) return true;
if (req.headers["x-authentik-username"]) return true;
const bearer = extractBearerToken(req);
if (bearer === INTERNAL_TOKEN) return true;
const headerToken = req.headers["x-proxy-internal-token"];
if (typeof headerToken === "string" && headerToken === INTERNAL_TOKEN) return true;
return false;
}
function sendUnauthorized(res) {
sendJson(res, 401, {
error: {
message: "unauthorized",
type: "authentication_error",
code: "invalid_internal_token",
},
});
}
// ---------------------------------------------------------------------------
// Route handlers
// ---------------------------------------------------------------------------
async function handleModels(req, res) {
if (!isAuthorized(req)) {
sendUnauthorized(res);
return;
}
const now = Math.floor(Date.now() / 1000);
sendJson(res, 200, {
object: "list",
data: allModelIds().map((id) => ({ id, object: "model", owned_by: "cursor", created: now })),
});
}
function handleHealth(_req, res) {
sendJson(res, 200, { status: "ok", timestamp: new Date().toISOString() });
}
async function handleChatCompletions(req, res) {
if (!isAuthorized(req)) {
sendUnauthorized(res);
return;
}
const requestId = randomUUID().replace(/-/g, "").slice(0, 24);
let body;
try {
body = await readJsonBody(req);
} catch (e) {
sendJson(res, 400, {
error: { message: e.message, type: "invalid_request_error", code: "invalid_body" },
});
return;
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
sendJson(res, 400, {
error: {
message: "messages is required and must be a non-empty array",
type: "invalid_request_error",
code: "invalid_messages",
},
});
return;
}
const stream = body.stream === true;
const model = normalizeModel(body.model);
const toolsCount = Array.isArray(body.tools) ? body.tools.length : 0;
const imageCount = hasImageParts(body.messages) ? body.messages.reduce((n, m) => {
if (!Array.isArray(m.content)) return n;
return n + m.content.filter((p) => p.type === "image_url").length;
}, 0) : 0;
const followUp = findSessionForFollowUp(body);
if (followUp) {
const { session, toolResults } = followUp;
console.error(
`[chat] id=${requestId} transport=acp model=${model} FOLLOWUP session=${session.sessionId} pending=${toolResults.length} stream=${stream}`
);
for (const tr of toolResults) {
const ok = session.resolveToolCall(tr.tool_call_id, messageContentToText(tr.content));
if (!ok) {
console.error(
`[chat] id=${requestId} tool_call_id=${tr.tool_call_id} not pending in session ${session.sessionId}`
);
}
}
await streamSessionTurn({ res, session, requestId, stream, blocks: null });
return;
}
console.error(
`[chat] id=${requestId} transport=acp model=${body.model} -> ${model} tools=${toolsCount} images=${imageCount} stream=${stream}`
);
let blocks;
try {
blocks = await openAiMessagesToAcpBlocks(body.messages);
} catch (e) {
sendJson(res, 400, {
error: { message: e.message, type: "invalid_request_error", code: "invalid_messages" },
});
return;
}
let session;
try {
session = await createSession({
tools: body.tools || [],
model,
log: (...args) => console.error(`[chat:${requestId}]`, ...args),
});
} catch (e) {
console.error(`[chat] id=${requestId} createSession error: ${e.message}`);
if (e.code === "too_many_sessions") {
sendJson(res, 429, {
error: {
message: e.message,
type: "rate_limit_error",
code: "too_many_sessions",
},
});
return;
}
if (!res.headersSent) {
sendJson(res, 500, { error: { message: e.message, type: "server_error", code: null } });
} else {
try {
res.end();
} catch {
/* ignore */
}
}
return;
}
await streamSessionTurn({ res, session, requestId, stream, blocks });
}
async function streamSessionTurn({ res, session, requestId, stream, blocks }) {
if (!stream) {
const result = await collectSessionTurn(session, blocks);
if (!res.headersSent) {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify(
nonStreamResponse({
id: requestId,
model: session.detectedModel || "auto",
text: result.text,
toolCalls: result.calls,
finishReason: result.finishReason,
usage: result.usage,
})
)
);
}
if (result.finishReason !== "tool_calls") {
await session.destroy();
}
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Request-Id", requestId);
res.flushHeaders();
res.write(":ok\n\n");
let isFirst = true;
let finished = false;
const handlers = {
onAssistantDelta: (delta) => {
if (res.writableEnded || finished) return;
const chunk = textChunk({
id: requestId,
model: session.detectedModel || "auto",
delta,
isFirst,
});
isFirst = false;
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
},
onToolCall: (call) => {
if (res.writableEnded || finished) return;
const chunk = toolCallChunk({
id: requestId,
model: session.detectedModel || "auto",
call,
isFirst,
});
isFirst = false;
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
},
onFinish: async (reason, _usage) => {
if (finished) return;
finished = true;
if (!res.writableEnded) {
const chunk = finishChunk({
id: requestId,
model: session.detectedModel || "auto",
finishReason: reason,
});
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}
if (reason === "stop" || reason === "error") {
await session.destroy();
}
},
};
session.runTurn(blocks, handlers);
}
function collectSessionTurn(session, blocks) {
return new Promise((resolve) => {
let text = "";
const calls = [];
let finishReason = "stop";
let usage = null;
session.runTurn(blocks, {
onAssistantDelta: (d) => {
text += d;
},
onToolCall: (c) => {
calls.push(c);
},
onFinish: (reason, u) => {
finishReason = reason;
usage = u;
resolve({ text, calls, finishReason, usage });
},
});
});
}
// ---------------------------------------------------------------------------
// Server bootstrap & graceful shutdown
// ---------------------------------------------------------------------------
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
return;
}
const url = new URL(req.url, "http://internal");
try {
if (req.method === "GET" && url.pathname === "/v1/models") {
await handleModels(req, res);
} else if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
await handleChatCompletions(req, res);
} else if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
handleHealth(req, res);
} else {
sendJson(res, 404, { error: { message: `not found: ${req.method} ${url.pathname}` } });
}
} catch (e) {
console.error(`[server] unhandled error: ${e.stack || e.message}`);
if (!res.headersSent) {
sendJson(res, 500, { error: { message: e.message, type: "server_error" } });
} else {
try {
res.end();
} catch {
/* ignore */
}
}
}
});
server.listen(PORT, () => {
console.error(`[server] cursor-agent-api-proxy (ACP) listening on :${PORT}`);
});
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.error(`[server] ${signal} received, shutting down...`);
server.close();
const force = setTimeout(() => {
console.error("[server] shutdown timeout, forcing exit");
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
try {
await destroyAllSessions();
} catch (e) {
console.error(`[server] error during shutdown: ${e.message}`);
}
clearTimeout(force);
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
+464
View File
@@ -0,0 +1,464 @@
/**
* Cursor-agent session manager (ACP transport).
*
* Each HTTP chat request creates an ACP session (`agent acp`):
* initialize → session/new (MCP stdio bridge) → session/prompt
*
* Client tools are advertised via in-process ProxyMcpServer. MCP tools/call
* parks until the HTTP client returns role:"tool" with the result.
*
* Built-in cursor-agent tools are denied via session/request_permission.
* Images are sent as native ACP image ContentBlocks (not via Read).
*/
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { AcpClient } from "./acp-client.mjs";
import { ProxyMcpServer } from "./mcp-server.mjs";
export const SESSION_TTL_MS = parseInt(process.env.PROXY_SESSION_TTL_MS || "120000", 10);
export const MAX_CONCURRENT_SESSIONS = parseInt(process.env.PROXY_MAX_CONCURRENT_SESSIONS || "4", 10);
export const TOOL_CALL_BATCH_MS = parseInt(process.env.PROXY_TOOL_CALL_BATCH_MS || "50", 10);
const REAL_AGENT = process.env.CURSOR_AGENT_BIN || "/usr/local/bin/agent.real";
const MCP_BRIDGE = process.env.MCP_BRIDGE_BIN || "/app/scripts/mcp-stdio-bridge.sh";
/** @type {Map<string, CursorSession>} */
const SESSIONS = new Map();
/**
* Resolve ACP model id from session/new response and requested OpenAI model id.
* @param {object} sessionResult session/new result
* @param {string} requested
*/
export function resolveModelId(sessionResult, requested) {
const available = sessionResult.models?.availableModels || [];
if (available.length > 0) {
if (!requested || requested === "auto") {
return sessionResult.models.currentModelId || available[0].modelId;
}
const exact = available.find((m) => m.modelId === requested || m.name === requested);
if (exact) return exact.modelId;
const byName = available.find((m) => m.name?.toLowerCase() === requested.toLowerCase());
if (byName) return byName.modelId;
const prefix = available.find(
(m) => m.modelId.startsWith(`${requested}[`) || m.modelId.startsWith(requested)
);
if (prefix) return prefix.modelId;
const contains = available.find((m) => m.modelId.includes(requested));
if (contains) return contains.modelId;
return sessionResult.models.currentModelId || available[0].modelId;
}
const modelOption = (sessionResult.configOptions || []).find(
(o) => o.id === "model" || o.category === "model"
);
return pickModelValueId(modelOption, requested);
}
/**
* Pick an ACP model config value for the requested OpenAI model id.
* @param {object} modelOption session/new config option with id "model"
* @param {string} requested e.g. "auto", "gpt-5.2"
*/
export function pickModelValueId(modelOption, requested) {
const options = modelOption?.options || [];
if (!options.length) return null;
if (!requested || requested === "auto") {
const auto = options.find((o) => o.value === "default[]" || o.name === "Auto");
return auto?.value || modelOption.currentValue || options[0].value;
}
const exact = options.find((o) => o.value === requested);
if (exact) return exact.value;
const byName = options.find((o) => o.name?.toLowerCase() === requested.toLowerCase());
if (byName) return byName.value;
const prefix = options.find(
(o) => o.value.startsWith(`${requested}[`) || o.value.startsWith(requested)
);
if (prefix) return prefix.value;
const contains = options.find((o) => o.value.includes(requested));
if (contains) return contains.value;
return null;
}
function pickPermissionOption(options, allow) {
if (!Array.isArray(options) || options.length === 0) return null;
const kindMatch = allow
? (o) => o.kind === "allow_once" || o.kind === "allow_always"
: (o) => o.kind === "reject_once" || o.kind === "reject_always";
return options.find(kindMatch) || (allow ? options[0] : options[options.length - 1]);
}
export function extractToolNameFromPermission(params) {
const tool = params?.toolCall;
if (!tool) return "";
if (tool.rawInput && typeof tool.rawInput === "object" && typeof tool.rawInput.name === "string") {
let name = tool.rawInput.name;
if (name.startsWith("mcp_client_")) {
name = name.slice("mcp_client_".length);
}
return name;
}
if (typeof tool.title === "string") {
const clientMcp = tool.title.match(/^client-([^:]+):\s*(\S+)/);
if (clientMcp) {
return clientMcp[2];
}
const m = tool.title.match(/^(\S+)/);
if (m) return m[1].replace(/:$/, "");
}
return "";
}
function mapStopReason(stopReason) {
if (stopReason === "end_turn") return "stop";
if (stopReason === "cancelled") return "error";
return "stop";
}
export class CursorSession {
constructor({ sessionId, tools, model, log }) {
this.sessionId = sessionId;
this.tools = tools || [];
this.clientToolNames = new Set(this.tools.map((t) => t.function?.name).filter(Boolean));
this.model = model || "auto";
this.log = log || console.error;
this.detectedModel = model || "auto";
this.createdAt = Date.now();
this.lastActivityAt = Date.now();
this.workDir = null;
this.cursorProc = null;
this.acp = null;
this.mcp = null;
this.acpSessionId = null;
this.timeoutHandle = null;
this._finishToolCallsTimer = null;
this.terminated = false;
this._handlers = null;
this._hasToolCallInTurn = false;
this._promptInFlight = false;
}
async start() {
this.workDir = mkdtempSync(join(tmpdir(), `cursor-${this.sessionId}-`));
const mcpTools = this.tools.map((t) => ({
name: t.function.name,
description: t.function.description,
inputSchema: t.function.parameters,
}));
const socketPath = join(this.workDir, "mcp.sock");
this.mcp = new ProxyMcpServer({
socketPath,
tools: mcpTools,
onToolCall: ({ toolCallId, name, arguments: args }) => {
if (!this._handlers) return;
this._hasToolCallInTurn = true;
this._handlers.onToolCall({
id: toolCallId,
name,
arguments: JSON.stringify(args || {}),
});
this._scheduleFinishToolCallsTurn();
},
log: (...args) => this.log(`[mcp:${this.sessionId}]`, ...args),
});
await this.mcp.start();
const args = ["acp"];
const env = { ...process.env };
if (process.env.CURSOR_API_KEY) env.CURSOR_API_KEY = process.env.CURSOR_API_KEY;
this.cursorProc = spawn(REAL_AGENT, args, {
cwd: this.workDir,
env,
stdio: ["pipe", "pipe", "pipe"],
});
this.acp = new AcpClient({
stdin: this.cursorProc.stdin,
stdout: this.cursorProc.stdout,
log: (...a) => this.log(`[acp:${this.sessionId}]`, ...a),
});
this.acp.onSessionUpdate = (params) => this._onSessionUpdate(params);
this.acp.onIncomingRequest = (method, params) => this._onIncomingRequest(method, params);
this.cursorProc.stderr.on("data", (d) =>
this.log(`[cursor:${this.sessionId} stderr] ${d.toString().trim()}`)
);
this.cursorProc.on("close", (code) => {
this.log(`[cursor:${this.sessionId}] process exited code=${code}`);
if (this._handlers) this._finishTurn("error", null);
});
this.cursorProc.on("error", (e) => {
this.log(`[cursor:${this.sessionId}] spawn error: ${e.message}`);
if (this._handlers) this._finishTurn("error", null);
});
await this.acp.initialize();
await this.acp.authenticateIfNeeded();
const mcpServers = [
{
name: "client",
command: MCP_BRIDGE,
args: [socketPath],
env: [],
},
];
let newSessionResult;
try {
newSessionResult = await this.acp.request("session/new", {
cwd: this.workDir,
mcpServers,
});
} catch (e) {
if (e.code === -32000) {
await this.acp.request("authenticate", { methodId: "cursor_login" });
newSessionResult = await this.acp.request("session/new", {
cwd: this.workDir,
mcpServers,
});
} else {
throw e;
}
}
this.acpSessionId = newSessionResult.sessionId;
const modelId = resolveModelId(newSessionResult, this.model);
const currentModelId = newSessionResult.models?.currentModelId;
if (modelId && modelId !== currentModelId) {
await this.acp.request("session/set_config_option", {
sessionId: this.acpSessionId,
configId: "model",
value: modelId,
});
}
this.detectedModel = modelId || currentModelId || this.model;
this._resetTimer();
return this;
}
async _onIncomingRequest(method, params) {
if (method === "session/request_permission") {
const toolName = extractToolNameFromPermission(params);
const allow = this.clientToolNames.has(toolName);
const option = pickPermissionOption(params.options, allow);
if (!option) {
return { outcome: { outcome: "cancelled" } };
}
const optionId = option.optionId || option.id;
return { outcome: { outcome: "selected", optionId } };
}
if (method === "cursor/ask_question") {
const first = params?.questions?.[0]?.options?.[0]?.id;
return { answers: first ? { [params.questions[0].id]: first } : {} };
}
if (method === "cursor/create_plan") {
return { approved: true };
}
if (
method === "cursor/update_todos" ||
method === "cursor/task" ||
method === "cursor/generate_image"
) {
return {};
}
this.log(`[acp] unhandled incoming method: ${method}`);
return {};
}
_onSessionUpdate(params) {
if (!this._handlers || params.sessionId !== this.acpSessionId) return;
const update = params.update;
if (!update) return;
if (update.sessionUpdate === "agent_message_chunk") {
const content = update.content;
if (content?.type === "text" && content.text) {
this._handlers.onAssistantDelta(content.text);
}
return;
}
// agent_thought_chunk and other updates are ignored for OpenAI output.
}
/**
* @param {Array<object>|null} blocks ACP ContentBlocks, or null for follow-up only
*/
runTurn(blocks, handlers) {
this._handlers = handlers;
this.lastActivityAt = Date.now();
this._resetTimer();
if (blocks === null) {
// Follow-up after tool_calls: same session/prompt is still in flight.
this._hasToolCallInTurn = false;
return;
}
this._hasToolCallInTurn = false;
this._promptInFlight = true;
this.acp
.request("session/prompt", {
sessionId: this.acpSessionId,
prompt: blocks,
})
.then((result) => {
this._promptInFlight = false;
if (!this._handlers) return;
if (this.mcp.pending.size > 0 || this._hasToolCallInTurn) {
this._scheduleFinishToolCallsTurn();
return;
}
this._finishTurn(mapStopReason(result.stopReason), null);
})
.catch((e) => {
this._promptInFlight = false;
this.log(`[acp] session/prompt error: ${e.message}`);
if (this._handlers) this._finishTurn("error", null);
});
}
resolveToolCall(toolCallId, result) {
const ok = this.mcp.resolveToolCall(toolCallId, result);
if (ok) {
this.lastActivityAt = Date.now();
this._resetTimer();
}
return ok;
}
_scheduleFinishToolCallsTurn() {
if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer);
this._finishToolCallsTimer = setTimeout(() => {
this._finishToolCallsTimer = null;
if (!this._handlers || !this._hasToolCallInTurn) return;
if (this.mcp.pending.size === 0) return;
this._finishTurn("tool_calls", null);
}, TOOL_CALL_BATCH_MS);
}
async destroy() {
if (this.terminated) return;
this.terminated = true;
if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer);
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
try {
this.acp?.close();
if (this.cursorProc && this.cursorProc.exitCode === null) {
this.cursorProc.kill("SIGTERM");
await new Promise((r) => {
const kill = setTimeout(() => {
try {
this.cursorProc?.kill("SIGKILL");
} catch {
/* ignore */
}
r();
}, 5000);
this.cursorProc?.once("close", () => {
clearTimeout(kill);
r();
});
});
}
} catch {
/* ignore */
}
try {
await this.mcp?.stop();
} catch {
/* ignore */
}
try {
if (this.workDir) rmSync(this.workDir, { recursive: true, force: true });
} catch {
/* ignore */
}
SESSIONS.delete(this.sessionId);
}
_resetTimer() {
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
this.timeoutHandle = setTimeout(async () => {
this.log(`[cursor:${this.sessionId}] TTL expired (idle ${SESSION_TTL_MS}ms)`);
await this.destroy();
}, SESSION_TTL_MS);
}
_finishTurn(reason, usage) {
if (!this._handlers) return;
const handlers = this._handlers;
this._handlers = null;
void Promise.resolve(handlers.onFinish(reason, usage));
}
}
export function getActiveSessionCount() {
return SESSIONS.size;
}
export async function createSession({ tools, model, log }) {
if (SESSIONS.size >= MAX_CONCURRENT_SESSIONS) {
const err = new Error(`too many concurrent sessions (max ${MAX_CONCURRENT_SESSIONS})`);
err.code = "too_many_sessions";
throw err;
}
const sessionId = `s_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
const session = new CursorSession({ sessionId, tools, model, log });
SESSIONS.set(sessionId, session);
try {
await session.start();
return session;
} catch (e) {
await session.destroy();
SESSIONS.delete(sessionId);
throw e;
}
}
export function findSessionForFollowUp(body) {
const toolResults = (body.messages || []).filter((m) => m.role === "tool");
if (toolResults.length === 0) return null;
for (const session of SESSIONS.values()) {
if (!session.mcp) continue;
for (const tr of toolResults) {
if (session.mcp.pending.has(tr.tool_call_id)) {
return { session, toolResults };
}
}
}
return null;
}
export function getSession(sessionId) {
return SESSIONS.get(sessionId);
}
export async function destroyAllSessions() {
const all = [...SESSIONS.values()];
SESSIONS.clear();
await Promise.all(all.map((s) => s.destroy()));
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { extractToolNameFromPermission } from "./session-manager.mjs";
describe("extractToolNameFromPermission", () => {
it("parses client MCP title format", () => {
const name = extractToolNameFromPermission({
toolCall: { title: "client-get_weather: get_weather" },
});
assert.equal(name, "get_weather");
});
it("strips mcp_client_ prefix from rawInput name", () => {
const name = extractToolNameFromPermission({
toolCall: { rawInput: { name: "mcp_client_get_weather" } },
});
assert.equal(name, "get_weather");
});
it("uses first token for built-in tools", () => {
const name = extractToolNameFromPermission({
toolCall: { title: "Shell: rm -rf /" },
});
assert.equal(name, "Shell");
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Helpers to build OpenAI-compatible chat.completion.chunk objects.
*
* Used both by the plain-chat path (text deltas only) and the tool-aware
* session path (text deltas + tool_calls deltas).
*/
/**
* Build a streaming chunk carrying an assistant text delta.
*/
export function textChunk({ id, model, delta, isFirst }) {
return {
id: `chatcmpl-${id}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: { ...(isFirst ? { role: "assistant" } : {}), content: delta },
finish_reason: null,
},
],
};
}
/**
* Build a streaming chunk carrying an OpenAI tool_calls delta. The client
* receives this when cursor-agent decides to call an MCP tool.
*/
export function toolCallChunk({ id, model, call, isFirst, index = 0 }) {
return {
id: `chatcmpl-${id}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
...(isFirst ? { role: "assistant" } : {}),
tool_calls: [
{
index,
id: call.id,
type: "function",
function: { name: call.name, arguments: call.arguments },
},
],
},
finish_reason: null,
},
],
};
}
/**
* Build the terminal chunk for a streaming response. finish_reason is one of
* "stop" | "tool_calls" | "length" | "error".
*/
export function finishChunk({ id, model, finishReason }) {
return {
id: `chatcmpl-${id}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
};
}
/**
* Build an error event (sent as a chunk before [DONE]).
*/
export function errorChunk({ id, model, message }) {
return {
error: { message, type: "server_error", code: null },
};
}
/**
* Build a non-streaming chat.completion response. Used when stream=false.
*/
export function nonStreamResponse({ id, model, text, toolCalls, finishReason, usage }) {
const message =
toolCalls && toolCalls.length > 0
? {
role: "assistant",
content: text || null,
tool_calls: toolCalls.map((c) => ({
id: c.id,
type: "function",
function: { name: c.name, arguments: c.arguments },
})),
}
: { role: "assistant", content: text || "" };
return {
id: `chatcmpl-${id}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
}