diff --git a/.env.example b/.env.example index 2303fb8e..cc10bb37 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,56 @@ LOG_LEVEL=info # Set to 1 for local deployments where you need to test proxies on private networks. # Leave empty or set to 0 for public-facing deployments (default: only public hosts allowed). ALLOW_PRIVATE_PROXY_HOSTS= + +# ========== Devin Sessions provider (optional) ========== +# Wraps Cognition's official Devin REST API (https://docs.devin.ai/api-reference/overview) +# as an OpenAI-compatible /v1/chat/completions endpoint. Independent of the +# Windsurf account pool — uses YOUR Devin API key and bills against your Devin +# org ACU budget. Leave blank to disable; when blank the `devin*` model entries +# are hidden from /v1/models. +# +# Service-user keys start with `apk_` (legacy v1) or `cog_` (current v3). +# Personal access tokens start with `apk_user_` (v3 only). +DEVIN_API_KEY= +# Override only if you're on Devin Enterprise with a custom host. +DEVIN_API_BASE=https://api.devin.ai +# Which Devin API surface this proxy targets for /v1/chat/completions: +# auto — try /v1/sessions; if Devin returns 401/403 and DEVIN_ORG_ID is set, +# fall back to /v3/organizations//sessions and cache the choice. +# v1 — always use the legacy /v1/sessions endpoints (apk_* keys). +# v3 — always use /v3/organizations//sessions (cog_* / apk_user_* keys). +# The /v1/devin/* REST passthrough is unaffected — it routes by path prefix. +DEVIN_API_VERSION=auto +# Required when DEVIN_API_VERSION=v3 (or when auto-detect falls back to v3). +# Service-user tokens are scoped to a single org; the org_id must be baked +# into v3 URLs because the upstream doesn't infer it from the bearer. Find +# it at https://app.devin.ai/settings/team. Format: org-<32 hex chars>. +DEVIN_ORG_ID= +# Optional defaults applied to every Devin session created by this proxy. +# Per-request overrides via OpenAI body.metadata.devin_snapshot_id / devin_playbook_id. +DEVIN_DEFAULT_SNAPSHOT_ID= +DEVIN_DEFAULT_PLAYBOOK_ID= +# Poll cadence + wall-clock cap for the synchronous /v1/chat/completions wrapper. +# Devin sessions are async tasks; this proxy polls until status_enum reaches +# blocked|finished|expired, or DEVIN_MAX_WAIT_MS elapses (returns finish_reason=length). +DEVIN_POLL_INTERVAL_MS=2000 +DEVIN_MAX_WAIT_MS=600000 +# In-process session reuse cache: maps conversation-history fingerprints to +# Devin session_ids so multi-turn OpenAI clients land on the same long-running +# session. X-Devin-Session-Id request header always overrides. +DEVIN_SESSION_CACHE_TTL_MS=3600000 +DEVIN_SESSION_CACHE_MAX_ENTRIES=1000 + +# ========== Devin Cloud REST passthrough (/v1/devin/*) ========== +# Mounted automatically when DEVIN_API_KEY is set. Lets clients reuse this +# proxy to drive Devin's full toolchain without ever holding the Devin key: +# /v1/devin/sessions v1 sessions (legacy) +# /v1/devin/attachments v1 attachments (upload) +# /v1/devin/knowledge | /playbooks | /secrets v1 org resources +# /v1/devin/v3/organizations//... v3 current API (RBAC) +# /v1/devin/v3/enterprise/... v3 enterprise admin +# /v1/devin/v2/enterprise/... v2 legacy enterprise (audit, +# consumption, api-keys, +# members, organizations) +# See docs/devin-provider.md for the full route table. Routes not in the +# allowlist return 404 even when DEVIN_API_KEY is configured. diff --git a/README.en.md b/README.en.md index 4f2e6ed3..af0e61f7 100644 --- a/README.en.md +++ b/README.en.md @@ -278,6 +278,10 @@ In your client's settings for **Custom OpenAI Compatible**: | `LS_DATA_DIR` | Linux: `/opt/windsurf/data`; macOS: `~/.windsurf/data` | Per-proxy LS data directory root. | | `DASHBOARD_PASSWORD` | empty | Dashboard password. Leave empty for no password. | | `ALLOW_PRIVATE_PROXY_HOSTS` | empty | Set to `1` to allow private/internal IPs (e.g., `192.168.x.x`, `10.x.x.x`) in proxy tests and login. Leave empty to only allow public addresses (default). | +| `DEVIN_API_KEY` | empty | Enables the Devin Sessions provider and the `/v1/devin/*` REST passthrough (`devin` / `devin-low` / `devin-medium` / `devin-high` / `devin-xhigh` / `devin-max` / `devin-fast` / `devin-deep` / `devin-acu-` models). Full guide: [`docs/devin-provider.md`](docs/devin-provider.md). | +| `DEVIN_API_BASE` | `https://api.devin.ai` | Override only if you're on Devin Enterprise with a custom host. | +| `DEVIN_POLL_INTERVAL_MS` / `DEVIN_MAX_WAIT_MS` | `2000` / `600000` | Polling cadence and wall-clock cap for the synchronous Devin session wrapper. | +| `DEVIN_DEFAULT_SNAPSHOT_ID` / `DEVIN_DEFAULT_PLAYBOOK_ID` | empty | Defaults applied to every Devin session; per-request overrides via `metadata.devin_snapshot_id` / `devin_playbook_id`. | | `CASCADE_REUSE_STRICT` | `0` | Set to `1` for strict conversation reuse mode (waits for same fingerprint). | | `CASCADE_REUSE_STRICT_RETRY_MS` | `60000` | Retry delay in ms for strict reuse mode. | | `CASCADE_REUSE_HASH_SYSTEM` | `0` | Set to `1` to include system messages in conversation reuse hash. | @@ -336,6 +340,27 @@ swe-1.5 / 1.5-fast / 1.6 / 1.6-fast · arena-fast · arena-smart +
+Devin Sessions (optional, requires DEVIN_API_KEY) + +Wraps Cognition's official Devin REST API ([docs](https://docs.devin.ai/api-reference/overview)) behind OpenAI / Anthropic endpoints. Completely independent of the Windsurf account pool — uses your own Devin API key and bills against your Devin org ACU budget: + +**Catalog (ordered by ACU budget, ascending):** + +- `devin` — Devin decides its own ACU budget +- `devin-low` — `max_acu_limit=2`, single-turn Q&A / quick checks +- `devin-medium` *(alias `devin-fast`)* — `max_acu_limit=5`, short tasks / tight ACU control +- `devin-high` — `max_acu_limit=20`, medium features +- `devin-xhigh` *(alias `devin-deep`)* — `max_acu_limit=50`, complex investigations +- `devin-max` — `max_acu_limit=100`, multi-PR / large refactors +- `devin-acu-` — dynamic alias where `N` is `1..10000` (e.g. `devin-acu-30`) + +**Devin Cloud REST passthrough (`/v1/devin/*`):** the full sessions / attachments / knowledge / playbooks / secrets CRUD set is reverse-proxied to `api.devin.ai` using the server-side `DEVIN_API_KEY` — clients neither need nor are allowed to send a Devin token themselves. + +Supports automatic fingerprint-based session reuse (the same OpenAI history continues the same Devin session), an `X-Devin-Session-Id` header to pin sessions manually, and `metadata.devin_*` fields (max_acu / snapshot_id / playbook_id / knowledge_ids / secret_ids / tags / structured_output_schema, etc.). Full guide: [`docs/devin-provider.md`](docs/devin-provider.md). + +
+ > **Free-account entitlements** typically include `gemini-2.5-flash`, `glm-4.7` / `glm-5` / `5.1`, `kimi-k2` / `k2.5` / `k2-6`, `qwen-3` and similar open-source models; Claude family, GPT family, and Opus / thinking variants require Pro. Each account's exact list shows up in the dashboard. > > **Tool-calling reliability (measured v2.0.82+):** Claude family is the most reliable (their training covered prompt-level tool protocols); GLM-4.7 / Kimi-K2.5 work for most cases via NLU fallback + optional retry-with-correction; GLM-5.1 is unreliable on the cascade backend (it often returns empty responses, no narration to recover from); GPT family is also limited because the cascade upstream doesn't carry `tools[]` schema. For Claude Code / Cline / Codex doing local tool calls, prefer `claude-haiku-4.5` or `claude-sonnet-4.6`. diff --git a/README.md b/README.md index 03b9eb80..184f8eff 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,10 @@ curl http://localhost:3003/v1/messages \ | `LS_PORT` | `42100` | LS gRPC 端口 | | `DASHBOARD_PASSWORD` | 空 | 后台密码 留空不设密码 | | `ALLOW_PRIVATE_PROXY_HOSTS` | 空 | 设为 `1` 允许在代理测试和登录时使用内网 IP(如 `192.168.x.x`、`10.x.x.x`)。默认留空仅允许公网地址 | +| `DEVIN_API_KEY` | 空 | 填了就启用 Devin Sessions provider 和 `/v1/devin/*` REST 反代(`devin` / `devin-low` / `devin-medium` / `devin-high` / `devin-xhigh` / `devin-max` / `devin-fast` / `devin-deep` / `devin-acu-` 模型)。完整说明见 [`docs/devin-provider.md`](docs/devin-provider.md) | +| `DEVIN_API_BASE` | `https://api.devin.ai` | Devin Enterprise 自定义 base URL | +| `DEVIN_POLL_INTERVAL_MS` / `DEVIN_MAX_WAIT_MS` | `2000` / `600000` | Devin session 同步等待的轮询间隔和总超时 | +| `DEVIN_DEFAULT_SNAPSHOT_ID` / `DEVIN_DEFAULT_PLAYBOOK_ID` | 空 | 给每个 Devin session 默认带上的 snapshot / playbook,可被请求 `metadata.devin_snapshot_id` / `devin_playbook_id` 覆盖 | ## Dashboard 功能面板 @@ -324,6 +328,27 @@ swe-1.5 / 1.5-fast / 1.6 / 1.6-fast · arena-fast · arena-smart +
+Devin Sessions(可选,需 DEVIN_API_KEY + +把 Cognition Devin 官方 REST API([docs](https://docs.devin.ai/api-reference/overview))包成 OpenAI / Anthropic 兼容端点。**完全独立于 Windsurf 账号池**,用你自己的 Devin org ACU 跑: + +**模型清单(按 ACU 预算从低到高):** + +- `devin` — 让 Devin 自己决定 ACU 预算 +- `devin-low` — `max_acu_limit=2`,单轮快问快答 +- `devin-medium` *(= `devin-fast`)* — `max_acu_limit=5`,短任务 / 严格控制 ACU +- `devin-high` — `max_acu_limit=20`,中型 feature +- `devin-xhigh` *(= `devin-deep`)* — `max_acu_limit=50`,复杂调研 +- `devin-max` — `max_acu_limit=100`,多 PR 串联 / 大型重构 +- `devin-acu-` — 动态别名,N 是 1~10000 的整数(例 `devin-acu-30`) + +**Devin Cloud REST 反代(`/v1/devin/*`):** sessions / attachments / knowledge / playbooks / secrets 全套 CRUD 透传到 `api.devin.ai`,统一用 server 端 `DEVIN_API_KEY` 鉴权,客户端不需要也不能传 Devin token。 + +支持自动指纹续聊(同一段 OpenAI history → 同一 Devin session)、`X-Devin-Session-Id` header 手动覆盖、`metadata.devin_*` 字段(max_acu / snapshot_id / playbook_id / knowledge_ids / secret_ids / tags / structured_output_schema 等)。完整说明见 [`docs/devin-provider.md`](docs/devin-provider.md)。 + +
+ > **免费账号 entitled 模型**主要是 `gemini-2.5-flash`、`glm-4.7`、`glm-5` / `5.1`、`kimi-k2` / `k2.5` / `k2-6`、`qwen-3` 等开源系列;Claude / GPT 全系 + Opus 系列要 Pro。具体每个账号的 entitled 清单看 dashboard。 > > **工具调用稳定性**(v2.0.82+ 实测):Claude family 走 `` 协议最稳;GLM-4.7 / Kimi-K2.5 走 NLU 兜底 + 可选 retry 大部分 case 能调;GLM-5.1 在 cascade 后端不稳(经常空回复 textLen=0),proxy 救不动;GPT 在 cascade 协议层不传 tools[] schema 也救不全。Claude Code 调本地工具优先 `claude-haiku-4.5` / `claude-sonnet-4.6`。 diff --git a/docs/devin-provider.md b/docs/devin-provider.md new file mode 100644 index 00000000..9b962989 --- /dev/null +++ b/docs/devin-provider.md @@ -0,0 +1,339 @@ +# Devin Sessions provider + +WindsurfAPI 可以选择性地把 [Cognition Devin](https://devin.ai) 的官方 REST API 包装成 OpenAI / Anthropic 兼容端点 —— 让任何已经在用 `/v1/chat/completions` 或 `/v1/messages` 的客户端(OpenAI SDK / Anthropic SDK / Claude Code / Cursor / Cline)也能直接驱动 Devin session。 + +> ⚠️ 该 provider **完全独立于 Windsurf 账号池**:用的是你自己的 Devin API key、消耗你自己的 Devin org ACU 预算。Windsurf 账号是否登录、Language Server 是否启动,都不影响它。 +> 没有 `DEVIN_API_KEY` 时,所有 `devin*` 模型都不会出现在 `/v1/models` 里,`/v1/devin/*` 反代路由也会统一回 503 `configuration_error`,不会触发任何上游调用。 + +## 模型清单 + +| 模型名 | `max_acu_limit` 默认 | 适用场景 | +| ------------------ | -------------------- | ----------------------------------------------------------------- | +| `devin` | 由 Devin 决定 | 一般 agent 任务,让 Devin 自己估算预算 | +| `devin-low` | `2` | 单轮快问快答、一次性脚本检查 | +| `devin-medium` *(= `devin-fast`)* | `5` | 短任务 / 单次问答 / 想严格控制 ACU 消耗的场景 | +| `devin-high` | `20` | 中型 feature、跨 3-5 个文件的小型 refactor | +| `devin-xhigh` *(= `devin-deep`)* | `50` | 大型 refactor / 复杂调研 | +| `devin-max` | `100` | 多 PR 串联、需要长时间跑的复杂任务 | +| `devin-acu-` | `` | 动态别名:把 N 替换成 1~10000 的整数,例 `devin-acu-30` | + +两组命名是等价的:`devin-fast` 和 `devin-medium` 走同一份 ACU 配额,`devin-deep` 和 `devin-xhigh` 同理。新代码推荐用 `low/medium/high/xhigh/max` 这套对称命名(与 EFFORT_LADDER 一致),老代码继续用 `devin-fast` / `devin-deep` 也完全 OK。 + +如果想在不重写代码的情况下临时改 `max_acu_limit`,在请求体的 `metadata.devin_max_acu` 里塞一个正整数就行(OpenAI body 原生支持 `metadata`),或者直接用 `model: devin-acu-N` 动态别名 —— 动态别名不需要在 `/v1/models` 里有对应条目,但 `parseDevinAcuAlias` 会把它识别成 `devin-sessions` provider 并应用到 session。 + +### 其它 metadata 传参 + +通过 OpenAI body 的 `metadata` 字段还能直接驱动 Devin 的其它会话级参数 —— 不需要走 `/v1/devin/*` 单独建 session: + +| `metadata.*` | 上游字段 | 说明 | +| ---------------------------------- | --------------------------------- | -------------------------------------------------------------------- | +| `devin_max_acu` | `max_acu_limit` | 覆盖模型自带的 ACU 上限 | +| `devin_snapshot_id` | `snapshot_id` | 指定环境快照 id(覆盖 `DEVIN_DEFAULT_SNAPSHOT_ID`) | +| `devin_playbook_id` | `playbook_id` | 指定 playbook id(覆盖 `DEVIN_DEFAULT_PLAYBOOK_ID`) | +| `devin_title` | `title` | session 标题,方便在 Devin dashboard 里识别 | +| `devin_structured_output_schema` | `structured_output_schema` | 结构化输出 schema,session 终态会带在 `x_devin.structured_output` 里 | +| `devin_knowledge_ids` (array) | `knowledge_ids` | 给 session 注入指定的 knowledge 条目(最多 64 条,空字符串会被剔除)| +| `devin_secret_ids` (array) | `secret_ids` | 注入指定的 org-level secret(最多 64 条) | +| `devin_session_secrets` (object) | `session_secrets` | 仅本次 session 生效的 key/value secret | +| `devin_tags` (array) | `tags` | session tag,最多 32 条 | +| `devin_unlisted: true` | `unlisted` | 不出现在公开 list | +| `devin_idempotent: true` | `idempotent` | Devin 端的幂等创建 | + +## 环境变量 + +复制 `.env.example` 顶上的 Devin 段,最少只要填 `DEVIN_API_KEY`。 + +| 变量 | 默认 | 说明 | +| ----------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------ | +| `DEVIN_API_KEY` | **必填** | Devin org 的 API key。Service-user key 以 `apk_`(v1 legacy)或 `cog_`(v3 当前)开头,个人 key 以 `apk_user_` 开头(v3)。 | +| `DEVIN_API_BASE` | `https://api.devin.ai` | 自定义只在 Devin Enterprise 上才需要。 | +| `DEVIN_API_VERSION` | `auto` | `auto` / `v1` / `v3`。`auto` 时优先用 `/v1/sessions`,遇到 401/403 且 `DEVIN_ORG_ID` 已配置就自动切到 `/v3/organizations//sessions` 并缓存选择。 | +| `DEVIN_ORG_ID` | — | `DEVIN_API_VERSION=v3`(或 auto 触发了 v3 fallback)时**必填**。`cog_*` / `apk_user_*` 这两类 token 不会从 bearer 推断 org,必须显式带 org_id。 | +| `DEVIN_DEFAULT_SNAPSHOT_ID` | — | 给每个 session 默认带上的 snapshot(仓库环境)id。可被 `metadata.devin_snapshot_id` 覆盖。 | +| `DEVIN_DEFAULT_PLAYBOOK_ID` | — | 给每个 session 默认带上的 playbook id。可被 `metadata.devin_playbook_id` 覆盖。 | +| `DEVIN_POLL_INTERVAL_MS` | `2000` | 轮询 `GET /v1/sessions/{id}` 的间隔。 | +| `DEVIN_MAX_WAIT_MS` | `600000` (10 分钟) | 同步等待 session 完成的总超时。超时后返回 `finish_reason=length`,但 session 仍然继续跑。 | +| `DEVIN_SESSION_CACHE_TTL_MS` | `3600000` (1 小时) | 指纹 → session_id 缓存的 TTL。 | +| `DEVIN_SESSION_CACHE_MAX_ENTRIES` | `1000` | 指纹缓存条目上限。超出后 LRU 淘汰。 | + +## 工作原理 + +Devin 不是模型推理服务,它是一个 async session 任务系统。每个"对话"实际上是: + +1. **创建 session**:把 OpenAI `messages[]` 拼成单个 prompt 字符串(带 `//` 标签),调用 `POST /v1/sessions` 拿到 `session_id`。 +2. **轮询**:每 `DEVIN_POLL_INTERVAL_MS` 调一次 `GET /v1/sessions/{id}`,直到 `status_enum` 变成 `finished` / `blocked` / `expired`,或者超过 `DEVIN_MAX_WAIT_MS`。 +3. **聚合**:把 session 在这一轮产生的所有 `devin_message` / `assistant_message` 事件拼成一段 assistant 文本,按 OpenAI 响应格式返回。如果 Devin 开了 PR,PR 链接会被追加到响应末尾。 + +### Session 复用(自动指纹续聊) + +普通 OpenAI 客户端的多轮对话长这样: + +- 第 1 次请求:`[{user: "hi"}]` +- 第 2 次请求:`[{user: "hi"}, {assistant: "hello"}, {user: "follow-up"}]` + +WindsurfAPI 会把"除最后一条 user 之外的所有消息"做一个 SHA-256 指纹,存进进程内缓存(默认 1 小时 TTL)。下一次请求带着扩展后的 history 来时,去掉新 user turn、计算指纹,如果命中就直接调 `POST /v1/sessions/{id}/message` 把新 user 发给原 session —— **不会重新创建一个 session 烧 ACU**。 + +每完成一轮,再用"完整 history(含本轮 assistant 回复)"的指纹把 session_id 重新存一份,保证下一轮还能命中。 + +### 手动指定 session + +如果你想精确控制 session 而不依赖指纹,在请求里加一个 HTTP header: + +```http +X-Devin-Session-Id: devin-XXXXXXXXXXXX +``` + +WindsurfAPI 会直接把这个 session 当成目标,把最后一条 user 消息作为 follow-up 发过去。指纹缓存被完全跳过。 + +返回响应里也会带这个 header,方便你下一轮继续用: + +```http +x-devin-session-id: devin-XXXXXXXXXXXX +x-devin-status: finished +``` + +### 流式(SSE) + +`stream: true` 走伪流式 —— 每次轮询 Devin session,如果发现新的 `devin_message` 事件,就以 OpenAI chat completion chunk 的形式发给客户端。这意味着: + +- 客户端能渐进地看到 Devin 的输出,不会被 OpenAI SDK 的超时干掉 +- 真正的 token-level streaming 是做不到的(Devin API 不暴露),但每条 Devin agent message 都会立刻 flush +- 心跳:每 15s 发一次 SSE comment 防代理掐连接 + +## OpenAI 客户端示例 + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://localhost:3003/v1", api_key="sk-anything") + +# 非流式(小心 10 分钟超时,长任务建议开 stream) +resp = client.chat.completions.create( + model="devin-fast", + messages=[ + {"role": "system", "content": "you are a code assistant"}, + {"role": "user", "content": "list the files in this repo"}, + ], + metadata={"devin_max_acu": 3}, +) +print(resp.choices[0].message.content) +print("session:", resp.x_devin["session_id"]) + +# 续聊 — 第二轮把第一轮的回复带上,指纹缓存会自动找到同一个 session +followup = client.chat.completions.create( + model="devin-fast", + messages=[ + {"role": "system", "content": "you are a code assistant"}, + {"role": "user", "content": "list the files in this repo"}, + {"role": "assistant", "content": resp.choices[0].message.content}, + {"role": "user", "content": "now read README.md"}, + ], +) +``` + +## Anthropic 客户端示例 + +`/v1/messages` 在内部走的是 OpenAI 翻译层 → `handleChatCompletions` → Devin adapter,所以 Anthropic SDK 也能直接拿来用: + +```python +import anthropic + +client = anthropic.Anthropic(base_url="http://localhost:3003", api_key="anything") +msg = client.messages.create( + model="devin", + max_tokens=1024, + messages=[{"role": "user", "content": "summarize https://github.com/WHUT666/WindsurfAPI"}], +) +print(msg.content[0].text) +``` + +## Devin Cloud REST 工具链反代(`/v1/devin/*`) + +除了把 chat completions 翻译给 Devin 之外,WindsurfAPI 还在 `/v1/devin/*` 下挂了 Devin Cloud 的完整 REST 工具链,方便不想自己管 `DEVIN_API_KEY` 的客户端把 sessions / attachments / knowledge / playbooks / secrets / 以及 v3 RBAC 和 v2 enterprise 已发布的 admin / audit / consumption 接口都走这一个代理。总共白名单一百多条路由,全部定义在 `src/handlers/devin-passthrough.js` 的 `ALLOWED_ROUTES` 中。 + +### 自省端点(不消耗 ACU,不需要 `DEVIN_API_KEY` 之外的额外配置) + +| 路由 | 方法 | 说明 | +| -------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| `/v1/devin/_proxy/info` | `GET` | 返回当前 Devin 配置(API 版本设定 / 缓存的实际版本 / org_id / 默认 snapshot/playbook / 轮询参数)。**不会**泄露 `DEVIN_API_KEY` 原文,仅给前 4 字符 + 末 4 字符的 mask。 | +| `/v1/devin/_proxy/info?probe=1` | `GET` | 同上,并且实际访问 `GET /v1/sessions?limit=1` 和 `GET /v3/organizations//sessions?limit=1` 探活,把上游状态码写进 `probe.{v1,v3,effective}` 返回。 | +| `/v1/devin/_proxy/routes` | `GET` | 返回完整的白名单路由表 JSON,供客户端枚举或自动生成 SDK。 | + +```bash +# 探活 + 看代理拿到的实际能力 +curl -s http://localhost:3003/v1/devin/_proxy/info?probe=1 -H "Authorization: Bearer $PROXY_API_KEY" | jq +# { +# "configured": true, +# "api_version_setting": "auto", +# "org_id": "org-xxxxxxxx", +# "api_key_mask": "cog_…yova", +# "cached_effective_version": "v3", +# "probe": { "v1": {"status": 401}, "v3": {"status": 200}, "effective": "v3" } +# } +``` + +### v1 (legacy,默认挂载名下;API key 需以 `apk_` / `apk_user_` 开头) + +| 路由 | 方法 | 上游 | +| --------------------------------------------- | ----------------- | ------------------------------------- | +| `/v1/devin/sessions` | `GET` / `POST` | `/v1/sessions` | +| `/v1/devin/sessions/:id` | `GET` / `DELETE` | `/v1/sessions/{id}` | +| `/v1/devin/sessions/:id/message` | `POST` | `/v1/sessions/{id}/message` | +| `/v1/devin/sessions/:id/tags` | `POST` / `PUT` | `/v1/sessions/{id}/tags` | +| `/v1/devin/attachments` | `POST` (multipart)| `/v1/attachments` | +| `/v1/devin/attachments/:id/file` | `GET` | `/v1/attachments/{id}/file` (302 透传)| +| `/v1/devin/knowledge` | `GET` / `POST` | `/v1/knowledge` | +| `/v1/devin/knowledge/:id` | `PATCH` / `PUT` / `DELETE` | `/v1/knowledge/{id}` | +| `/v1/devin/playbooks` | `GET` / `POST` | `/v1/playbooks` | +| `/v1/devin/playbooks/:id` | `GET` / `PATCH` / `PUT` / `DELETE` | `/v1/playbooks/{id}` | +| `/v1/devin/secrets` | `GET` / `POST` | `/v1/secrets` | +| `/v1/devin/secrets/:id` | `GET` / `DELETE` | `/v1/secrets/{id}` | + +### v3 organizations(当前主推 API,RBAC / service-user token 以 `cog_` 开头) + +全部路由都需要在客户端 URL 中显式带上 `org_id`,代理不会从 API key 中推断 —— 企业版下同一个 service-user token 可以跨 org 生效。 + +| 路由 | 方法 | +| ----------------------------------------------------------------------------- | ------------------------------------------ | +| `/v1/devin/v3/organizations/:org_id/sessions` | `GET` / `POST` | +| `/v1/devin/v3/organizations/:org_id/sessions/insights` | `GET` | +| `/v1/devin/v3/organizations/:org_id/sessions/:devin_id` | `GET` / `DELETE` | +| `/v1/devin/v3/organizations/:org_id/sessions/:devin_id/messages` | `GET` (paginated) / `POST` | +| `/v1/devin/v3/organizations/:org_id/sessions/:devin_id/tags` | `POST` (append) / `PUT` (replace) | +| `/v1/devin/v3/organizations/:org_id/sessions/:devin_id/archive` | `POST` | +| `/v1/devin/v3/organizations/:org_id/sessions/:devin_id/attachments` | `POST` (multipart) | +| `/v1/devin/v3/organizations/:org_id/sessions/:devin_id/insights/generate` | `POST` | +| `/v1/devin/v3/organizations/:org_id/knowledge/notes` | `GET` / `POST` | +| `/v1/devin/v3/organizations/:org_id/knowledge/notes/:note_id` | `GET` / `PATCH` / `PUT` / `DELETE` | +| `/v1/devin/v3/organizations/:org_id/playbooks` | `GET` / `POST` | +| `/v1/devin/v3/organizations/:org_id/playbooks/:playbook_id` | `GET` / `PATCH` / `PUT` / `DELETE` | +| `/v1/devin/v3/organizations/:org_id/secrets` | `GET` / `POST` | +| `/v1/devin/v3/organizations/:org_id/secrets/:secret_id` | `GET` / `DELETE` | +| `/v1/devin/v3/organizations/:org_id/attachments` | `POST` (multipart) | +| `/v1/devin/v3/organizations/:org_id/attachments/:attachment_id/file` | `GET` (302 透传) | +| `/v1/devin/v3/organizations/:org_id/service-users` | `GET` / `POST` | +| `/v1/devin/v3/organizations/:org_id/service-users/:user_id` | `GET` / `DELETE` | +| `/v1/devin/v3/organizations/:org_id/users` | `GET` | +| `/v1/devin/v3/organizations/:org_id/users/:user_id` | `GET` | + +### v3 enterprise(企业 admin跨 org 操作) + +| 路由 | 方法 | +| --------------------------------------------------------- | -------------------------------------- | +| `/v1/devin/v3/enterprise/organizations` | `GET` (list orgs in enterprise) | +| `/v1/devin/v3/enterprise/sessions` | `GET` | +| `/v1/devin/v3/enterprise/sessions/:devin_id` | `GET` / `DELETE` | +| `/v1/devin/v3/enterprise/sessions/:devin_id/messages` | `POST` | +| `/v1/devin/v3/enterprise/sessions/:devin_id/archive` | `POST` | +| `/v1/devin/v3/enterprise/sessions/:devin_id/tags` | `POST` (append) / `PUT` (replace) | +| `/v1/devin/v3/enterprise/knowledge/notes` | `GET` / `POST` | +| `/v1/devin/v3/enterprise/knowledge/notes/:note_id` | `GET` / `PATCH` / `PUT` / `DELETE` | +| `/v1/devin/v3/enterprise/playbooks` | `GET` / `POST` | +| `/v1/devin/v3/enterprise/playbooks/:playbook_id` | `GET` / `PATCH` / `PUT` / `DELETE` | + +### v2 enterprise(legacy,计费 / audit / member 管理) + +v3 还没完全接管的部分都在 v2 上,这些路由需要 enterprise admin personal API key(`apk_user_` 且带 enterprise admin role)。 + +| 路由 | 方法 | +| --------------------------------------------------------- | ---------------- | +| `/v1/devin/v2/enterprise/audit-logs` | `GET` | +| `/v1/devin/v2/enterprise/consumption/cycles` | `GET` | +| `/v1/devin/v2/enterprise/consumption/daily` | `GET` | +| `/v1/devin/v2/enterprise/consumption/user-daily` | `GET` | +| `/v1/devin/v2/enterprise/consumption/pr-metrics` | `GET` | +| `/v1/devin/v2/enterprise/consumption/sessions-metrics` | `GET` | +| `/v1/devin/v2/enterprise/consumption/searches-metrics` | `GET` | +| `/v1/devin/v2/enterprise/consumption/usage-metrics` | `GET` | +| `/v1/devin/v2/enterprise/api-keys` | `GET` / `POST` / `DELETE` (后者是 bulk revoke) | +| `/v1/devin/v2/enterprise/api-keys/:key_id` | `DELETE` | +| `/v1/devin/v2/enterprise/members` | `GET` | +| `/v1/devin/v2/enterprise/members/invite` | `POST` | +| `/v1/devin/v2/enterprise/members/roles` | `GET` / `PATCH` | +| `/v1/devin/v2/enterprise/members/roles/migrate` | `POST` | +| `/v1/devin/v2/enterprise/members/organizations` | `GET` | +| `/v1/devin/v2/enterprise/members/:member_id` | `GET` / `DELETE` | +| `/v1/devin/v2/enterprise/organizations` | `GET` / `POST` | +| `/v1/devin/v2/enterprise/groups` | `GET` / `POST` | +| `/v1/devin/v2/enterprise/groups/:group_id` | `GET` | +| `/v1/devin/v2/enterprise/org-group-limits` | `GET` / `PATCH` | +| `/v1/devin/v2/enterprise/infrastructure/hypervisors` | `GET` | + +关键行为: + +- 所有路由都用 server 端的 `DEVIN_API_KEY` 作为上游 Bearer token,**客户端不需要、也不能传 Devin token**(防止凭证泄漏给反代消费方)。 +- 仍受 proxy 自己的 `API_KEY` 网关保护(和 `/v1/chat/completions` 同一把锁)。 +- multipart 上传是流式的,10 MB 内不缓冲;attachment 下载默认返回上游 302 让客户端直拉 presigned URL。 +- 上游非 2xx 的响应体(Devin 的 JSON 错误详情)原样透传过来,方便客户端按 4xx/5xx 处理。 +- `DEVIN_API_KEY` 没设的时候这些路由统一回 503 `configuration_error`。 +- 路由白名单写死在 `src/handlers/devin-passthrough.js` 的 `ALLOWED_ROUTES` 里,未列出的 Devin endpoint 会回 404 —— 新增 endpoint 必须显式登记。 + +示例: + +```bash +# 上传一份 README 给 Devin 作为后续 session 的 attachment +curl -s http://localhost:3003/v1/devin/attachments \ + -H "Authorization: Bearer $API_KEY" \ + -F file=@README.md + +# 列最近的 session +curl -s http://localhost:3003/v1/devin/sessions?limit=20 \ + -H "Authorization: Bearer $API_KEY" | jq . + +# 给已有 session 加 tag +curl -s http://localhost:3003/v1/devin/sessions/devin-abc123/tags \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"tags":["triage","demo"]}' + +# 写一条 knowledge +curl -s http://localhost:3003/v1/devin/knowledge \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"name":"deploy","contents":"npm run deploy","trigger":"When deploying"}' + +# v3:帮某个用户创建一个 session(需要 ImpersonateOrgSessions 权限) +curl -s http://localhost:3003/v1/devin/v3/organizations/org-abc/sessions \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"prompt":"Fix issue #42","create_as_user_id":"user-zzz"}' + +# v3:给已有 session 发后续消息(注意是 messages 不是 message) +curl -s http://localhost:3003/v1/devin/v3/organizations/org-abc/sessions/devin-xyz/messages \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"message":"please retry with verbose logs"}' + +# v3:归档 session 以便后期查询 +curl -s -X POST http://localhost:3003/v1/devin/v3/organizations/org-abc/sessions/devin-xyz/archive \ + -H "Authorization: Bearer $API_KEY" + +# v2:查看 enterprise 本周期的 ACU 消耗 +curl -s 'http://localhost:3003/v1/devin/v2/enterprise/consumption/cycles?limit=12' \ + -H "Authorization: Bearer $API_KEY" | jq . + +# v2:拉近 30 天的 audit log +curl -s 'http://localhost:3003/v1/devin/v2/enterprise/audit-logs?days=30' \ + -H "Authorization: Bearer $API_KEY" | jq . +``` + +## 限制与注意 + +- **OpenAI `tools` 字段被忽略**:Devin 有自己的工具调用系统(shell、浏览器、文件读写),不接受外部 tool schema。如果客户端在 OpenAI 请求里塞了 `tools`,Devin 会照常工作但不会发回 `tool_calls`。 +- **多模态降级**:Devin v1 API 是纯文本的。`image_url` / `input_image` 部分会被替换成 `[image: ]` 占位符告诉 Devin "用户上传了一张图"。需要视觉理解请用别的 provider。 +- **缓存只在单进程内**:指纹 → session 缓存不是分布式的。多 worker 部署 / 横向扩容时不同副本可能会创建不同的 session。需要严格 session 路由的话用 `X-Devin-Session-Id` header。 +- **超时与 ACU**:`DEVIN_MAX_WAIT_MS` 只控制我们这边等多久;超时后 OpenAI 响应 `finish_reason=length` 但 **Devin session 还会继续跑** —— 这是 Devin 的设计。下次同一会话的请求会通过指纹缓存把 session 接回来,看到完整结果。 +- **没有 Windsurf 账号池保护**:Devin 401 / 429 不会被自动 fallback 到其他账号 —— 这里只有一份 Devin API key。错误会原样翻译成 OpenAI 错误格式返回。 + +## 拉取 PR / 结构化输出 + +Devin session 经常会以"开 PR"作为终态。adapter 检测到 `session.pull_request.url` 时,会把 PR 链接附加到 assistant 回复末尾: + +``` +(Devin 给的回复内容…) + +--- +Pull request: https://github.com/owner/repo/pull/123 +``` + +如果你用 [Devin 的 `structured_output_schema`](https://docs.devin.ai/api-reference/structured-output),可以在请求 `metadata` 里塞 `devin_structured_output_schema`,结构化结果会附在响应的 `x_devin.structured_output` 字段里 —— OpenAI 标准 `choices` 仍是文本,方便兼容那些不认识 `x_devin` 的客户端。 diff --git a/package.json b/package.json index c2f82985..7e82d726 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "windsurf-api", - "version": "2.0.95", + "version": "2.0.97", "description": "Windsurf to OpenAI + Anthropic compatible API proxy. Turns Windsurf's 107 AI models (Claude, GPT, Gemini, DeepSeek, Grok, Qwen, Kimi, GLM, SWE) into dual-protocol API endpoints. Zero npm deps.", "type": "module", "main": "src/index.js", diff --git a/src/config.js b/src/config.js index 38d16215..40b3439e 100644 --- a/src/config.js +++ b/src/config.js @@ -91,6 +91,35 @@ export const config = { // Proxy testing allowPrivateProxyHosts: process.env.ALLOW_PRIVATE_PROXY_HOSTS === '1', + + // Devin Sessions provider (https://docs.devin.ai/api-reference/overview) + // Optional upstream that wraps Cognition's official Devin REST API as + // an OpenAI-compatible /v1/chat/completions endpoint. Independent of + // the Windsurf account pool — set DEVIN_API_KEY to enable. + devinApiKey: process.env.DEVIN_API_KEY || '', + devinApiBase: process.env.DEVIN_API_BASE || 'https://api.devin.ai', + // API surface the chat adapter should target. + // 'v1' — legacy /v1/sessions/... (org-scoped via apk_* key) + // 'v3' — current /v3/organizations//sessions/... (cog_*, apk_user_*, etc.) + // 'auto' — try v1 first; on 401/403, fall back to v3 and cache the choice. + // The passthrough at /v1/devin/* is unaffected — it routes by path prefix. + devinApiVersion: (process.env.DEVIN_API_VERSION || 'auto').toLowerCase(), + // Required for v3 (and used by /v1/devin/_proxy/info for introspection). + // Service-user tokens (`cog_*`) and personal access tokens (`apk_user_*`) only + // authenticate against v3 endpoints, which need the org_id baked into the path. + devinOrgId: process.env.DEVIN_ORG_ID || '', + devinDefaultSnapshotId: process.env.DEVIN_DEFAULT_SNAPSHOT_ID || '', + devinDefaultPlaybookId: process.env.DEVIN_DEFAULT_PLAYBOOK_ID || '', + // Poll cadence and wall-clock cap for synchronous chat completions. + // Devin sessions are async tasks; we poll the GET /v1/sessions/{id} + // endpoint every devinPollIntervalMs until a terminal status is reached + // or devinMaxWaitMs elapses. + devinPollIntervalMs: parseInt(process.env.DEVIN_POLL_INTERVAL_MS || '2000', 10), + devinMaxWaitMs: parseInt(process.env.DEVIN_MAX_WAIT_MS || '600000', 10), + // Fingerprint-based session reuse cache (process-local; X-Devin-Session-Id + // header always overrides). + devinSessionCacheTtlMs: parseInt(process.env.DEVIN_SESSION_CACHE_TTL_MS || String(60 * 60 * 1000), 10), + devinSessionCacheMaxEntries: parseInt(process.env.DEVIN_SESSION_CACHE_MAX_ENTRIES || '1000', 10), }; const levels = { debug: 0, info: 1, warn: 2, error: 3 }; diff --git a/src/devin-client.js b/src/devin-client.js new file mode 100644 index 00000000..506524a0 --- /dev/null +++ b/src/devin-client.js @@ -0,0 +1,462 @@ +/** + * Devin REST client — thin wrapper around api.devin.ai. + * + * Speaks both API surfaces: + * + * v1 (legacy, org-scoped via apk_* key): + * POST /v1/sessions + * GET /v1/sessions/{id} ← messages embedded in response + * POST /v1/sessions/{id}/message + * + * v3 (current, RBAC + service-user / cog_* tokens): + * POST /v3/organizations/{org_id}/sessions + * GET /v3/organizations/{org_id}/sessions/{id} + * GET /v3/organizations/{org_id}/sessions/{id}/messages + * POST /v3/organizations/{org_id}/sessions/{id}/messages + * + * The v3 surface is required for the new service-user token family + * (`cog_*`, May 2026+) — those keys 401 against every v1 endpoint, so a + * v1-only client breaks the moment the operator rotates their token. + * + * Caller picks the version with config.devinApiVersion: + * 'v1' — force v1 (legacy keys) + * 'v3' — force v3 (needs config.devinOrgId; cog_* / apk_user_* keys) + * 'auto' — try v1, on 401/403 swap to v3 and cache the choice + * + * Auth: `Authorization: Bearer `. Keys are pulled from + * config.devinApiKey at call time (not at import) so dashboard-driven + * key rotation works without a process restart. + * + * Response normalization: v3 returns sessions and messages on separate + * endpoints with `source: 'user'|'devin'` instead of v1's + * `type: 'user_message'|'devin_message'`, and uses `status` instead of + * `status_enum`. `getSession` returns a normalized v1-shaped object so + * handlers/devin-chat.js never has to branch. + * + * Zero npm deps — uses global fetch (Node 20+). + */ + +import { config, log } from './config.js'; + +class DevinApiError extends Error { + constructor(message, { status = 0, body = null, endpoint = '' } = {}) { + super(message); + this.name = 'DevinApiError'; + this.status = status; + this.body = body; + this.endpoint = endpoint; + } +} + +function requireApiKey() { + const key = config.devinApiKey; + if (!key) { + throw new DevinApiError('DEVIN_API_KEY not configured', { status: 503 }); + } + return key; +} + +async function devinFetch(method, path, { body, signal, apiKey } = {}) { + const base = (config.devinApiBase || 'https://api.devin.ai').replace(/\/+$/, ''); + const url = `${base}${path}`; + const headers = { + 'Authorization': `Bearer ${apiKey || requireApiKey()}`, + 'Accept': 'application/json', + }; + const init = { method, headers, signal }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + let res; + try { + res = await fetch(url, init); + } catch (err) { + if (err?.name === 'AbortError') throw err; + throw new DevinApiError(`Network error calling Devin API: ${err.message}`, { + status: 0, endpoint: path, + }); + } + let parsed = null; + const text = await res.text(); + if (text) { + try { parsed = JSON.parse(text); } catch { parsed = text; } + } + if (!res.ok) { + const message = typeof parsed === 'object' && parsed?.detail + ? (Array.isArray(parsed.detail) ? JSON.stringify(parsed.detail) : String(parsed.detail)) + : `Devin API ${res.status} on ${method} ${path}`; + throw new DevinApiError(message, { status: res.status, body: parsed, endpoint: path }); + } + return parsed; +} + +// ───────────────────────────────────────────────────────────────────────── +// API-version routing +// ───────────────────────────────────────────────────────────────────────── + +/** + * Cached "auto" decision. Populated by `_probeForAuto()` once per process + * (per DEVIN_API_KEY rotation): one 200/401 round-trip on POST /v1/sessions + * is enough to determine whether the current key works on v1. + * + * Keyed by the API key itself so a config-level key swap (without restart) + * triggers a fresh probe instead of reusing stale routing. + */ +const _autoVersionCache = new Map(); // apiKey → 'v1' | 'v3' + +function configuredVersion() { + const v = String(config.devinApiVersion || 'auto').toLowerCase(); + if (v === 'v1' || v === 'v3') return v; + return 'auto'; +} + +function requireOrgId() { + const orgId = config.devinOrgId; + if (!orgId) { + throw new DevinApiError( + 'DEVIN_ORG_ID is required when DEVIN_API_VERSION=v3 (or when auto-detect picks v3). ' + + 'Find your org id at https://app.devin.ai/settings/team or via GET /v2/enterprise/organizations.', + { status: 503 }, + ); + } + return orgId; +} + +/** + * Resolve the effective API version for an outgoing call. + * - 'v1' / 'v3' explicit → return verbatim. + * - 'auto' → return cached probe result; null if not yet probed (caller probes lazily). + */ +function effectiveVersion(apiKey) { + const cfg = configuredVersion(); + if (cfg !== 'auto') return cfg; + return _autoVersionCache.get(apiKey || config.devinApiKey) || null; +} + +/** + * Lazily probe which API surface the current key speaks. Called from + * createSession when version='auto' and no decision is cached yet. + * + * Strategy: try a real v1 call. If it 401/403s, mark this key as v3. + * The probe is the actual createSession call (passed via `probeFn`), so we + * don't burn a wasted round-trip — we just intercept its first 401 and retry + * on v3 instead. + */ +function _markAutoVersion(apiKey, version) { + const key = apiKey || config.devinApiKey; + if (!key) return; + _autoVersionCache.set(key, version); + log.info(`Devin: auto-detected API version=${version} for key=${maskKey(key)}`); +} + +/** Test-only: clear the auto-detect cache. */ +export function _clearAutoVersionCache() { + _autoVersionCache.clear(); +} + +function maskKey(key) { + if (!key || typeof key !== 'string') return ''; + if (key.length <= 8) return '***'; + return key.slice(0, 4) + '…' + key.slice(-4); +} + +/** + * Build the upstream path for a session-scoped operation. + * + * @param {'create'|'get'|'send_message'|'list_messages'} op + * @param {string} sessionId + * @param {'v1'|'v3'} version + */ +function pathFor(op, sessionId, version) { + if (version === 'v3') { + const orgId = requireOrgId(); + const prefix = `/v3/organizations/${encodeURIComponent(orgId)}/sessions`; + if (op === 'create') return prefix; + const sid = encodeURIComponent(sessionId); + if (op === 'get') return `${prefix}/${sid}`; + if (op === 'send_message') return `${prefix}/${sid}/messages`; + if (op === 'list_messages') return `${prefix}/${sid}/messages`; + throw new DevinApiError(`Unknown session op: ${op}`, { status: 500 }); + } + // v1 + if (op === 'create') return '/v1/sessions'; + const sid = encodeURIComponent(sessionId); + if (op === 'get') return `/v1/sessions/${sid}`; + if (op === 'send_message') return `/v1/sessions/${sid}/message`; + if (op === 'list_messages') return `/v1/sessions/${sid}`; + throw new DevinApiError(`Unknown session op: ${op}`, { status: 500 }); +} + +/** + * Run a session-scoped fetch, honoring config.devinApiVersion. In 'auto' + * mode the first 401/403 from v1 silently retries on v3 and caches the + * choice for subsequent calls. + */ +async function sessionFetch(op, { sessionId, body, signal, apiKey } = {}) { + const effectiveKey = apiKey || config.devinApiKey; + let version = effectiveVersion(effectiveKey); + if (!version) { + // First call in auto mode — start with v1 unless an explicit org_id + // hints v3 was intended. + version = config.devinOrgId ? 'v3' : 'v1'; + } + + const method = (op === 'create' || op === 'send_message') ? 'POST' : 'GET'; + const path = pathFor(op, sessionId, version); + try { + const parsed = await devinFetch(method, path, { body, signal, apiKey }); + if (configuredVersion() === 'auto' && !_autoVersionCache.has(effectiveKey)) { + _markAutoVersion(effectiveKey, version); + } + return { parsed, version }; + } catch (err) { + // Auto-detect fallback: 401/403 on v1 → retry on v3. + // + // Skip the fallback when DEVIN_ORG_ID isn't configured — we can't form + // a v3 path without it. The caller's options at that point are either + // (a) set DEVIN_ORG_ID and DEVIN_API_VERSION=v3 explicitly, or + // (b) treat the 401 verbatim. Re-raising the original 401 is more + // actionable than swallowing it under a "missing org_id" error. + if ( + configuredVersion() === 'auto' && + version === 'v1' && + err instanceof DevinApiError && + (err.status === 401 || err.status === 403) && + config.devinOrgId + ) { + log.info(`Devin: v1 returned ${err.status}, retrying on v3 (key=${maskKey(effectiveKey)})`); + const v3Path = pathFor(op, sessionId, 'v3'); + const parsed = await devinFetch(method, v3Path, { body, signal, apiKey }); + _markAutoVersion(effectiveKey, 'v3'); + return { parsed, version: 'v3' }; + } + throw err; + } +} + +// ───────────────────────────────────────────────────────────────────────── +// Public surface +// ───────────────────────────────────────────────────────────────────────── + +/** + * Create a new Devin session. + * + * @param {object} params + * @param {string} params.prompt - Required. Initial task prompt. + * @param {string} [params.snapshot_id] + * @param {string} [params.playbook_id] + * @param {string[]} [params.tags] + * @param {string} [params.title] + * @param {number} [params.max_acu_limit] + * @param {boolean} [params.idempotent] + * @param {boolean} [params.unlisted] + * @param {object} [params.structured_output_schema] + * @param {object} [opts] + * @param {AbortSignal} [opts.signal] + * @param {string} [opts.apiKey] - Override the configured DEVIN_API_KEY. + * @returns {Promise<{session_id: string, url: string, is_new_session?: boolean, _api_version?: 'v1'|'v3'}>} + */ +export async function createSession(params, opts = {}) { + if (!params || typeof params.prompt !== 'string' || !params.prompt.length) { + throw new DevinApiError('createSession: prompt is required', { status: 400 }); + } + const body = { prompt: params.prompt }; + for (const k of ['snapshot_id', 'playbook_id', 'tags', 'title', 'max_acu_limit', 'idempotent', 'unlisted', 'knowledge_ids', 'secret_ids', 'session_secrets', 'structured_output_schema']) { + if (params[k] !== undefined && params[k] !== null) body[k] = params[k]; + } + log.debug(`Devin: createSession (acu=${params.max_acu_limit ?? 'default'}, snapshot=${params.snapshot_id || 'none'})`); + const { parsed, version } = await sessionFetch('create', { + body, signal: opts.signal, apiKey: opts.apiKey, + }); + if (parsed && typeof parsed === 'object') parsed._api_version = version; + return parsed; +} + +/** + * Get current session status + messages, normalized to the v1 shape so + * handlers can treat both API surfaces interchangeably: + * { + * session_id, + * status, // raw status string + * status_enum, // working|blocked|finished|expired|... (derived for v3) + * messages: [ // each entry: {event_id, type, message, ...} + * { event_id, type: 'user_message'|'devin_message'|..., message, ... }, + * ], + * structured_output, + * pull_request, // {url, ...} or null + * ... + * } + * + * For v3, this issues TWO requests — the session detail and the + * /messages page — and merges them. The extra round trip is acceptable + * because the chat adapter polls on a 2s cadence anyway. + */ +export async function getSession(sessionId, opts = {}) { + if (!sessionId) throw new DevinApiError('getSession: sessionId is required', { status: 400 }); + const { parsed, version } = await sessionFetch('get', { + sessionId, signal: opts.signal, apiKey: opts.apiKey, + }); + if (version === 'v1') { + return parsed; + } + // v3: fetch messages separately and normalize. + let messagesPayload = null; + try { + messagesPayload = await devinFetch( + 'GET', + pathFor('list_messages', sessionId, 'v3') + '?limit=200', + { signal: opts.signal, apiKey: opts.apiKey }, + ); + } catch (err) { + // Best-effort — if messages 404 we still return the session shell so + // status polls work. Log and continue. + log.warn(`Devin v3 list_messages failed for ${sessionId.slice(0, 8)}: ${err.message}`); + } + return normalizeV3Session(parsed, messagesPayload); +} + +/** + * Send a follow-up message to an active session. + * Returns null on success, or {detail} when the session is in a non-running state. + */ +export async function sendMessage(sessionId, message, opts = {}) { + if (!sessionId) throw new DevinApiError('sendMessage: sessionId is required', { status: 400 }); + if (typeof message !== 'string' || !message.length) { + throw new DevinApiError('sendMessage: message is required', { status: 400 }); + } + // v1 expects {"message": "..."}; v3 also accepts {"message": "..."}. + // We send the same body shape to both surfaces; sessionFetch picks the path. + const { parsed } = await sessionFetch('send_message', { + sessionId, body: { message }, signal: opts.signal, apiKey: opts.apiKey, + }); + return parsed; +} + +/** Terminal statuses where Devin is no longer making progress on the current turn. */ +export const TERMINAL_STATUSES = new Set(['blocked', 'finished', 'expired']); + +/** Statuses where the session is alive and accepting messages. */ +export const ACTIVE_STATUSES = new Set(['working', 'resumed', 'resume_requested', 'resume_requested_frontend']); + +/** + * Convert a v3 session detail + paginated message list into the v1 shape + * the rest of the codebase expects. + * + * v3 message shape (from /v3/organizations//sessions//messages): + * {items: [{event_id, source, message, created_at, ...}], end_cursor, has_next_page} + * + * v1 message shape (embedded in GET /v1/sessions/): + * {messages: [{event_id, type: 'user_message'|'devin_message'|..., message, ...}]} + * + * source mapping: + * 'user' → type: 'user_message' + * 'devin' → type: 'devin_message' + * 'agent' → type: 'devin_message' (defensive — Devin docs use both) + * '' → type: '' (carry through verbatim) + */ +export function normalizeV3Session(sessionDetail, messagesPayload) { + const out = { ...(sessionDetail || {}) }; + // v3 has `status` and `status_detail`; v1 has `status` + `status_enum`. + // status_detail is the fine-grained working/blocked/expired hint we need. + if (!out.status_enum) { + out.status_enum = sessionDetail?.status_detail || sessionDetail?.status || null; + } + // pull_requests (v3, array) vs pull_request (v1, single object). + if (!out.pull_request && Array.isArray(sessionDetail?.pull_requests) && sessionDetail.pull_requests.length > 0) { + out.pull_request = sessionDetail.pull_requests[0]; + } + const items = Array.isArray(messagesPayload?.items) + ? messagesPayload.items + : Array.isArray(messagesPayload) + ? messagesPayload + : []; + out.messages = items.map((ev) => { + if (!ev || typeof ev !== 'object') return ev; + const src = String(ev.source || '').toLowerCase(); + let type = ev.type; + if (!type) { + if (src === 'user' || src === 'human') type = 'user_message'; + else if (src === 'devin' || src === 'agent' || src === 'assistant') type = 'devin_message'; + else type = src || 'event'; + } + return { ...ev, type }; + }); + return out; +} + +/** + * Poll a session until it reaches a terminal status, an abort signal fires, + * or the timeout elapses. + * + * When `progressDetector` is provided, the loop will NOT treat a terminal + * status as final until the detector returns true for the polled session. + * This handles the follow-up case: right after `sendMessage` the Devin API + * may insert the new user_message into the session immediately while still + * reporting status_enum=blocked from the prior turn. A naive event-after- + * cursor check would exit before the actual assistant reply lands; the + * detector lets the caller demand a meaningful state change (e.g., a new + * assistant message after a given cursor). + * + * @param {string} sessionId + * @param {object} opts + * @param {number} [opts.intervalMs] + * @param {number} [opts.maxWaitMs] + * @param {AbortSignal} [opts.signal] + * @param {string} [opts.apiKey] + * @param {(session: object) => void} [opts.onProgress] - Called after every successful poll. + * @param {(session: object) => boolean} [opts.progressDetector] - Returns true + * when this poll's session contains the progress the caller is waiting for. + * Terminal exit is suppressed until it returns true. + * @returns {Promise<{session: object, timedOut: boolean}>} + */ +export async function pollUntilTerminal(sessionId, opts = {}) { + const intervalMs = Math.max(250, opts.intervalMs ?? config.devinPollIntervalMs); + const maxWaitMs = Math.max(intervalMs, opts.maxWaitMs ?? config.devinMaxWaitMs); + const deadline = Date.now() + maxWaitMs; + const detector = typeof opts.progressDetector === 'function' ? opts.progressDetector : null; + let session = null; + while (true) { + if (opts.signal?.aborted) throw new DevinApiError('aborted', { status: 499 }); + session = await getSession(sessionId, { signal: opts.signal, apiKey: opts.apiKey }); + if (typeof opts.onProgress === 'function') { + try { opts.onProgress(session); } catch (e) { log.warn(`Devin onProgress hook threw: ${e.message}`); } + } + const statusEnum = session?.status_enum || null; + if (statusEnum && TERMINAL_STATUSES.has(statusEnum)) { + if (!detector || detector(session)) { + return { session, timedOut: false }; + } + } + if (Date.now() >= deadline) { + return { session, timedOut: true }; + } + await sleep(Math.min(intervalMs, Math.max(50, deadline - Date.now())), opts.signal); + } +} + +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => { cleanup(); resolve(); }, ms); + const onAbort = () => { cleanup(); reject(new DevinApiError('aborted', { status: 499 })); }; + const cleanup = () => { + clearTimeout(t); + if (signal) signal.removeEventListener('abort', onAbort); + }; + if (signal) { + if (signal.aborted) { cleanup(); return reject(new DevinApiError('aborted', { status: 499 })); } + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +export { DevinApiError }; + +// Test helpers — exported so tests can introspect routing without +// touching internal globals. +export const _internals = { + effectiveVersion, + configuredVersion, + pathFor, + _autoVersionCache, +}; diff --git a/src/devin-session-cache.js b/src/devin-session-cache.js new file mode 100644 index 00000000..5eb6e3dc --- /dev/null +++ b/src/devin-session-cache.js @@ -0,0 +1,119 @@ +/** + * Devin session reuse cache — maps a conversation-history fingerprint to + * an existing Devin session_id so multi-turn OpenAI-style chats land on + * the same long-running Devin task instead of spawning a new ACU-burning + * session every request. + * + * Strategy: + * - Fingerprint = SHA-256 of (callerKey + N-1 prior messages, normalized). + * - When a request arrives, look up the fingerprint of (messages without + * the new tail user turn). Hit → reuse session, send the tail as a + * follow-up. Miss → create a new session with the full conversation + * baked into the prompt. + * - After the turn completes, store the AFTER fingerprint (all messages + * including the new assistant reply) → session_id, so the NEXT request + * in the conversation hits the cache. + * + * Entries TTL out after DEVIN_SESSION_CACHE_TTL_MS (default 1h) to bound + * memory and to avoid resurrecting sessions Devin has already expired. + * + * Single-process only — horizontal replicas have independent caches. + * Explicit X-Devin-Session-Id header always overrides the cache. + */ + +import { createHash } from 'crypto'; +import { config, log } from './config.js'; + +const _cache = new Map(); // fingerprint -> { sessionId, expiresAt } + +function ttlMs() { + return Math.max(60_000, config.devinSessionCacheTtlMs ?? 60 * 60 * 1000); +} + +function maxEntries() { + return Math.max(10, config.devinSessionCacheMaxEntries ?? 1000); +} + +/** + * Normalize a message list into a stable string for hashing. + * - Role + content only (drops volatile fields like name, tool_call_id timestamps) + * - Coerces array-content to text by joining text parts + */ +function normalizeMessages(messages) { + if (!Array.isArray(messages)) return ''; + return messages.map((m) => { + if (!m || typeof m !== 'object') return ''; + const role = String(m.role || 'user'); + let content = ''; + if (typeof m.content === 'string') { + content = m.content; + } else if (Array.isArray(m.content)) { + content = m.content + .map((p) => (typeof p === 'string' ? p : (p?.text || ''))) + .filter(Boolean) + .join('\n'); + } + return `${role}\n${content}`; + }).join('\n----\n'); +} + +/** + * Compute a fingerprint for a conversation prefix. + * @param {string} callerKey + * @param {Array} messages + * @returns {string} hex digest + */ +export function fingerprint(callerKey, messages) { + const hash = createHash('sha256'); + hash.update(`devin-v1\n${callerKey || ''}\n`); + hash.update(normalizeMessages(messages)); + return hash.digest('hex'); +} + +/** Look up a session for a given fingerprint. Returns null if absent / expired. */ +export function lookup(fp) { + if (!fp) return null; + const entry = _cache.get(fp); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { + _cache.delete(fp); + return null; + } + return entry.sessionId; +} + +/** Insert or refresh a fingerprint → session_id mapping. */ +export function store(fp, sessionId) { + if (!fp || !sessionId) return; + if (_cache.size >= maxEntries()) { + // Drop oldest entry (insertion order). Simple LRU-ish; good enough + // for our use case where TTL does the heavy lifting. + const oldestKey = _cache.keys().next().value; + if (oldestKey) _cache.delete(oldestKey); + } + _cache.set(fp, { sessionId, expiresAt: Date.now() + ttlMs() }); + log.debug(`Devin session cache store fp=${fp.slice(0, 12)} session=${sessionId.slice(0, 8)} (size=${_cache.size})`); +} + +/** Invalidate a specific session id across all fingerprints. */ +export function invalidateSession(sessionId) { + if (!sessionId) return 0; + let removed = 0; + for (const [fp, entry] of _cache.entries()) { + if (entry.sessionId === sessionId) { + _cache.delete(fp); + removed++; + } + } + return removed; +} + +/** Clear the entire cache. Test-only. */ +export function clear() { + _cache.clear(); +} + +/** Internal: number of entries (test helper). */ +export function size() { + return _cache.size; +} diff --git a/src/handlers/chat.js b/src/handlers/chat.js index 5ad75333..71ddc564 100644 --- a/src/handlers/chat.js +++ b/src/handlers/chat.js @@ -32,6 +32,7 @@ import { } from '../cascade-native-bridge.js'; import { sanitizeText, sanitizeToolCall, PathSanitizeStream } from '../sanitize.js'; import { registerSseController } from '../sse-registry.js'; +import { handleDevinChat } from './devin-chat.js'; const HEARTBEAT_MS = 15_000; const QUEUE_RETRY_MS = 1_000; @@ -1227,6 +1228,16 @@ export function shouldAutoFallback(body, context, result) { } export async function handleChatCompletions(body, context = {}) { + // Devin Sessions provider short-circuit — routes to handleDevinChat + // BEFORE any Windsurf-specific logic (account pool, language server, + // cascade reuse, drought handling). The Devin upstream is a completely + // separate REST API (api.devin.ai) and doesn't share any of that + // infrastructure. Detection: resolve the model name and check provider. + const devinModelKey = resolveModel(body?.model || ''); + const devinModelInfo = devinModelKey ? getModelInfo(devinModelKey) : null; + if (devinModelInfo?.provider === 'devin-sessions') { + return handleDevinChat(body, context); + } // v2.0.88 (audit H-3) — compute original cache key BEFORE any // fallback rewrite. We pass it into the inner via context so a // successful fallback writes into the cache slot the NEXT identical diff --git a/src/handlers/devin-chat.js b/src/handlers/devin-chat.js new file mode 100644 index 00000000..322c4f62 --- /dev/null +++ b/src/handlers/devin-chat.js @@ -0,0 +1,649 @@ +/** + * Devin Sessions → OpenAI Chat Completions adapter. + * + * Translates a synchronous /v1/chat/completions request into: + * 1. Find-or-create a Devin session (fingerprint cache + X-Devin-Session-Id header) + * 2. Send the new user turn (only on cache HIT; new sessions get the + * full conversation baked into the initial prompt) + * 3. Poll session until status_enum reaches a terminal state + * ('blocked' / 'finished' / 'expired') OR DEVIN_MAX_WAIT_MS elapses + * 4. Aggregate Devin-side messages emitted during this turn into a + * single assistant message and return in OpenAI shape + * + * Stream support is pseudo-streaming: we poll the session and emit + * deltas as new Devin messages appear, keeping the SSE alive with + * heartbeat comments. This matches how the rest of the proxy bridges + * polled upstreams (cascade) to SSE clients. + * + * The adapter is registered as `provider: 'devin-sessions'` in + * src/models.js. handleChatCompletions short-circuits to handleDevinChat + * before touching the Windsurf account pool / language server code path. + */ + +import { randomUUID } from 'crypto'; +import { createSession, getSession, sendMessage, pollUntilTerminal, TERMINAL_STATUSES, DevinApiError } from '../devin-client.js'; +import { fingerprint as fpDevin, lookup as cacheLookup, store as cacheStore, invalidateSession as cacheInvalidateSession } from '../devin-session-cache.js'; +import { config, log } from '../config.js'; +import { getModelInfo, resolveModel } from '../models.js'; + +const HEARTBEAT_MS = 15_000; + +/** + * Extract a single header value, case-insensitively, from a request headers + * object (Node http: lowercased; raw fetch: original case). + */ +function header(headers, name) { + if (!headers) return null; + const lower = name.toLowerCase(); + if (typeof headers.get === 'function') { + return headers.get(lower) || headers.get(name) || null; + } + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v; + } + return null; +} + +/** + * Convert OpenAI `messages[]` (system/user/assistant/tool) into a single + * Devin prompt string. Used when creating a new session from a fresh + * conversation. Devin's prompt is plain text — multimodal parts are + * coerced to text descriptions. + */ +function messagesToPrompt(messages, { promptPrefix = '' } = {}) { + const lines = []; + if (promptPrefix) lines.push(promptPrefix); + for (const m of messages || []) { + if (!m || typeof m !== 'object') continue; + const role = String(m.role || 'user'); + const text = contentToText(m.content); + if (role === 'system') { + // System messages become a labelled preamble. Devin doesn't have a + // first-class system role, but it respects guidance in the prompt. + lines.push(`\n${text}\n`); + } else if (role === 'tool') { + const name = m.name || m.tool_call_id || 'tool'; + lines.push(`\n${text}\n`); + } else { + lines.push(`<${role}>\n${text}\n`); + } + } + return lines.filter(Boolean).join('\n\n'); +} + +function contentToText(content) { + if (content == null) return ''; + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return String(content); + const parts = []; + for (const p of content) { + if (typeof p === 'string') { parts.push(p); continue; } + if (!p || typeof p !== 'object') continue; + if (typeof p.text === 'string') { parts.push(p.text); continue; } + if (p.type === 'image_url' && p.image_url?.url) { + parts.push(`[image: ${shortenUrl(p.image_url.url)}]`); + continue; + } + if (p.type === 'input_image' && p.image_url) { + parts.push(`[image: ${shortenUrl(p.image_url)}]`); + continue; + } + if (p.type === 'tool_use') { + parts.push(`[tool_use ${p.name || ''}: ${JSON.stringify(p.input || {})}]`); + continue; + } + if (p.type === 'tool_result') { + parts.push(`[tool_result: ${contentToText(p.content)}]`); + continue; + } + } + return parts.join('\n'); +} + +function shortenUrl(url) { + if (typeof url !== 'string') return ''; + if (url.startsWith('data:')) return url.slice(0, 32) + '…(base64)'; + return url.length > 120 ? url.slice(0, 120) + '…' : url; +} + +/** + * Pick the tail user turn that should be sent as a follow-up message + * when reusing an existing session. Returns null if the last message + * isn't a user message (in which case we bake the whole history into + * a fresh session instead). + */ +function tailUserMessage(messages) { + if (!Array.isArray(messages) || messages.length === 0) return null; + const last = messages[messages.length - 1]; + if (!last || last.role !== 'user') return null; + const text = contentToText(last.content); + return text || null; +} + +/** + * Build a progressDetector predicate for pollUntilTerminal: returns true when + * the polled session has at least one assistant message after `cursor`. Used + * to suppress early exit when Devin reports status_enum=terminal but the + * session hasn't yet produced the actual reply for the new turn (the API + * inserts the new user_message synchronously, so naive "any-event-after" + * checks fire too early). + */ +function makeAssistantProgressDetector(cursor) { + return (session) => hasAssistantMessageAfter(session, cursor); +} + +function hasAssistantMessageAfter(session, cursor) { + const messages = Array.isArray(session?.messages) ? session.messages : []; + if (messages.length === 0) return false; + if (cursor == null) { + return messages.some((m) => m && ASSISTANT_MESSAGE_TYPES.has(m.type) && typeof m.message === 'string' && m.message.length > 0); + } + let past = false; + let found = false; + for (const ev of messages) { + if (!ev) continue; + if (!past) { + if (ev.event_id === cursor) past = true; + continue; + } + if (ASSISTANT_MESSAGE_TYPES.has(ev.type) && typeof ev.message === 'string' && ev.message.length > 0) { + found = true; + break; + } + } + // Cursor not found in current message list (Devin may have pruned older + // events) — be liberal: any assistant message in the list counts. + if (!past) { + return messages.some((m) => m && ASSISTANT_MESSAGE_TYPES.has(m.type) && typeof m.message === 'string' && m.message.length > 0); + } + return found; +} + +/** Devin SessionMessage event types that represent assistant-side output. */ +const ASSISTANT_MESSAGE_TYPES = new Set([ + 'devin_message', + 'assistant_message', + 'agent_message', +]); + +/** + * Extract new assistant-side messages emitted since `sinceEventId`. + * Returns an array of message bodies (each one a separate Devin event), + * plus the cursor advanced past the last extracted event. + * + * When sinceEventId is null, treat the start of the session as the boundary + * (everything assistant-side counts). If sinceEventId is non-null but not + * found in the message list (Devin pruned old events), fall back to the + * messages after the last user_message event. + */ +function extractNewAssistantMessages(session, sinceEventId) { + if (!session?.messages?.length) { + return { messages: [], newSinceEventId: sinceEventId, prInfo: null }; + } + let collecting = sinceEventId == null; + let cursor = sinceEventId; + const out = []; + for (const ev of session.messages) { + if (!ev) continue; + const eventId = ev.event_id || ''; + if (!collecting) { + if (eventId === sinceEventId) collecting = true; + continue; + } + if (eventId) cursor = eventId; + if (ASSISTANT_MESSAGE_TYPES.has(ev.type)) { + const msg = typeof ev.message === 'string' ? ev.message : ''; + if (msg) out.push(msg); + } + } + // Fallback: sinceEventId wasn't in the list — return everything after + // the last user_message event. + if (!collecting) { + let started = false; + out.length = 0; + cursor = sinceEventId; + for (const ev of session.messages) { + if (!ev) continue; + if (ev.type === 'user_message' || ev.type === 'human_message') { + started = true; + out.length = 0; + continue; + } + if (!started) continue; + if (ev.event_id) cursor = ev.event_id; + if (ASSISTANT_MESSAGE_TYPES.has(ev.type)) { + const msg = typeof ev.message === 'string' ? ev.message : ''; + if (msg) out.push(msg); + } + } + } + return { + messages: out, + newSinceEventId: cursor, + prInfo: session?.pull_request || null, + }; +} + +/** Convenience: joined-text view used by the non-stream path. */ +function extractNewAssistantText(session, sinceEventId) { + const { messages, newSinceEventId, prInfo } = extractNewAssistantMessages(session, sinceEventId); + return { text: messages.join('\n\n'), newSinceEventId, prInfo }; +} + +/** Last event_id in the session messages — used as a starting cursor. */ +function lastEventId(session) { + if (!session?.messages?.length) return null; + for (let i = session.messages.length - 1; i >= 0; i--) { + if (session.messages[i]?.event_id) return session.messages[i].event_id; + } + return null; +} + +/** + * Build the per-model Devin session params based on the resolved model. + * + * Model → ACU mapping (precedence: low to high): + * devin → no override (Devin chooses) + * devin-low → max_acu_limit = 2 + * devin-medium / -fast → max_acu_limit = 5 + * devin-high → max_acu_limit = 20 + * devin-xhigh / -deep → max_acu_limit = 50 + * devin-max → max_acu_limit = 100 + * devin-acu- → max_acu_limit = N (1 ≤ N ≤ 10000), see + * parseDevinAcuAlias in models.js + * body.metadata.devin_max_acu (per-request) → wins over model alias + * + * Plus pass-through for snapshot_id / playbook_id / title / + * structured_output_schema / knowledge_ids / secret_ids / session_secrets + * / tags / unlisted via `body.metadata.devin_` keys. + */ +function deriveSessionParamsForModel(modelKey, info, body) { + const params = {}; + if (info?.devinMaxAcu) params.max_acu_limit = info.devinMaxAcu; + // Allow per-request override via OpenAI metadata.devin_max_acu — clients + // that want fine-grained budget control can supply it without us + // inventing dozens of model variants. + const metaAcu = body?.metadata?.devin_max_acu; + if (Number.isFinite(metaAcu) && metaAcu > 0) params.max_acu_limit = metaAcu; + if (config.devinDefaultSnapshotId) params.snapshot_id = config.devinDefaultSnapshotId; + if (body?.metadata?.devin_snapshot_id) params.snapshot_id = String(body.metadata.devin_snapshot_id); + if (config.devinDefaultPlaybookId) params.playbook_id = config.devinDefaultPlaybookId; + if (body?.metadata?.devin_playbook_id) params.playbook_id = String(body.metadata.devin_playbook_id); + if (body?.metadata?.devin_title) params.title = String(body.metadata.devin_title); + if (body?.metadata?.devin_structured_output_schema && typeof body.metadata.devin_structured_output_schema === 'object') { + params.structured_output_schema = body.metadata.devin_structured_output_schema; + } + // Extra Devin toolchain hooks. Allow OpenAI clients to wire knowledge + // entries / secrets / tags directly from a chat completions call so + // they don't have to detour through /v1/devin/* for every session. + if (Array.isArray(body?.metadata?.devin_knowledge_ids)) { + params.knowledge_ids = body.metadata.devin_knowledge_ids + .filter((s) => typeof s === 'string' && s).slice(0, 64); + } + if (Array.isArray(body?.metadata?.devin_secret_ids)) { + params.secret_ids = body.metadata.devin_secret_ids + .filter((s) => typeof s === 'string' && s).slice(0, 64); + } + if (body?.metadata?.devin_session_secrets && typeof body.metadata.devin_session_secrets === 'object') { + params.session_secrets = body.metadata.devin_session_secrets; + } + if (Array.isArray(body?.metadata?.devin_tags)) { + params.tags = body.metadata.devin_tags + .filter((s) => typeof s === 'string' && s).slice(0, 32); + } + if (body?.metadata?.devin_unlisted === true) params.unlisted = true; + if (body?.metadata?.devin_idempotent === true) params.idempotent = true; + return params; +} + +/** + * Resolve the Devin session for this request: + * 1. Explicit X-Devin-Session-Id header → use that, send tail user message. + * 2. Fingerprint cache hit on messages[0..n-1] → reuse, send tail. + * 3. Otherwise create a new session with the full conversation as prompt. + */ +async function resolveSession({ messages, callerKey, headers, modelKey, modelInfo, body, signal }) { + const explicit = header(headers, 'x-devin-session-id'); + if (explicit) { + log.info(`Devin: explicit session id from header session=${explicit.slice(0, 8)}`); + const tail = tailUserMessage(messages); + let cursor = null; + if (tail) { + try { + // Snapshot event cursor BEFORE sending so the poll loop knows what's + // new vs. what was already in the session from a prior turn. + const snapshot = await getSession(explicit, { signal }); + cursor = lastEventId(snapshot); + const res = await sendMessage(explicit, tail, { signal }); + if (res && typeof res === 'object' && res.detail) { + throw new DevinApiError(`Devin session ${explicit} not running: ${res.detail}`, { status: 409 }); + } + } catch (err) { + if (err instanceof DevinApiError && err.status === 404) { + throw new DevinApiError(`Devin session ${explicit} not found`, { status: 404 }); + } + throw err; + } + } + return { sessionId: explicit, source: 'header', cursor }; + } + + // Try fingerprint reuse — hash everything except the tail user message. + let cursor = null; + if (messages.length >= 2 && messages[messages.length - 1]?.role === 'user') { + const prefix = messages.slice(0, -1); + const fp = fpDevin(callerKey || '', prefix); + const sid = cacheLookup(fp); + if (sid) { + const tail = tailUserMessage(messages); + if (tail) { + try { + // Snapshot event cursor BEFORE sending so we know what's new. + const snapshot = await getSession(sid, { signal }); + cursor = lastEventId(snapshot); + const sendRes = await sendMessage(sid, tail, { signal }); + if (sendRes && typeof sendRes === 'object' && sendRes.detail) { + log.info(`Devin: cached session ${sid.slice(0, 8)} not running (${sendRes.detail}) — creating new`); + cacheInvalidateSession(sid); + } else { + log.info(`Devin: reuse cached session=${sid.slice(0, 8)} fp=${fp.slice(0, 12)}`); + return { sessionId: sid, source: 'fingerprint', cursor }; + } + } catch (err) { + if (err instanceof DevinApiError && (err.status === 404 || err.status === 410)) { + log.info(`Devin: cached session ${sid.slice(0, 8)} stale (${err.status}) — creating new`); + cacheInvalidateSession(sid); + } else { + throw err; + } + } + } + } + } + + // Fresh session + const prompt = messagesToPrompt(messages); + const params = deriveSessionParamsForModel(modelKey, modelInfo, body); + const created = await createSession({ ...params, prompt }, { signal }); + log.info(`Devin: created session=${created.session_id?.slice(0, 8)} model=${modelKey}`); + return { sessionId: created.session_id, source: 'created', cursor: null, sessionUrl: created.url }; +} + +/** + * Public entry — handle a Devin chat completions request. + * Returns the same shape as handleChatCompletions: + * non-stream: { status, body, headers? } + * stream: { status, stream: true, headers, handler(res) } + */ +export async function handleDevinChat(body, context = {}) { + if (!config.devinApiKey) { + return { + status: 503, + body: { + error: { + message: 'Devin provider is not configured (set DEVIN_API_KEY).', + type: 'configuration_error', + }, + }, + }; + } + const modelKey = resolveModel(body.model) || 'devin'; + const modelInfo = getModelInfo(modelKey); + const messages = Array.isArray(body.messages) ? body.messages : []; + const callerKey = context.callerKey || body.__callerKey || ''; + const headers = context.headers || {}; + const wantStream = !!body.stream; + const chatId = 'chatcmpl-' + randomUUID().replace(/-/g, '').slice(0, 24); + const created = Math.floor(Date.now() / 1000); + const displayModel = modelInfo?.name || modelKey; + + if (messages.length === 0) { + return { status: 400, body: { error: { message: 'messages must be non-empty', type: 'invalid_request' } } }; + } + + const abortController = new AbortController(); + + let resolved; + try { + resolved = await resolveSession({ + messages, callerKey, headers, modelKey, modelInfo, body, + signal: abortController.signal, + }); + } catch (err) { + return devinErrorToOpenAI(err, displayModel); + } + const sessionId = resolved.sessionId; + let cursor = resolved.cursor; + + if (!wantStream) { + let session; + try { + const result = await pollUntilTerminal(sessionId, { + signal: abortController.signal, + onProgress: () => {}, + progressDetector: makeAssistantProgressDetector(cursor), + }); + session = result.session; + const timedOut = result.timedOut; + const { text, newSinceEventId, prInfo } = extractNewAssistantText(session, cursor); + // Persist fingerprint→session for the next turn so the user can keep + // chatting against this session via the OpenAI client without + // tracking session ids themselves. + const afterMessages = [...messages, { role: 'assistant', content: text }]; + cacheStore(fpDevin(callerKey, afterMessages), sessionId); + const content = composeAssistantContent(text, prInfo, session); + return { + status: 200, + headers: { + 'x-devin-session-id': sessionId, + 'x-devin-status': session?.status_enum || session?.status || '', + }, + body: buildOpenAIResponseBody({ + chatId, created, displayModel, content, session, + finishReason: timedOut ? 'length' : 'stop', + usagePrompt: messages, usageCompletion: text, + }), + }; + } catch (err) { + return devinErrorToOpenAI(err, displayModel); + } + } + + // Streaming (pseudo-SSE via polling) + return { + status: 200, + stream: true, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + 'x-devin-session-id': sessionId, + }, + async handler(res) { + const send = (data) => { + if (!res.writableEnded) res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + const sendDone = () => { + if (!res.writableEnded) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }; + const sendError = (message, type = 'upstream_error', code = null) => { + send({ + id: chatId, object: 'chat.completion.chunk', created, model: displayModel, + choices: [{ index: 0, delta: {}, finish_reason: 'error' }], + error: { message, type, code }, + }); + sendDone(); + }; + + res.on('close', () => { + if (!res.writableEnded) abortController.abort(); + }); + + const heartbeat = setInterval(() => { + if (!res.writableEnded) res.write(': ping\n\n'); + }, HEARTBEAT_MS); + const stopHeartbeat = () => clearInterval(heartbeat); + res.on('close', stopHeartbeat); + + // Send the role chunk immediately so clients see the assistant boundary. + send({ + id: chatId, object: 'chat.completion.chunk', created, model: displayModel, + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], + }); + + let fullText = ''; + let lastSession = null; + let timedOut = false; + const initialCursor = cursor; + try { + const result = await pollUntilTerminal(sessionId, { + signal: abortController.signal, + progressDetector: makeAssistantProgressDetector(initialCursor), + onProgress: (session) => { + lastSession = session; + const { messages: newMsgs, newSinceEventId } = extractNewAssistantMessages(session, cursor); + if (newMsgs.length > 0) { + for (const msg of newMsgs) { + const sep = fullText ? '\n\n' : ''; + const delta = sep + msg; + fullText += delta; + send({ + id: chatId, object: 'chat.completion.chunk', created, model: displayModel, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + }); + } + cursor = newSinceEventId || cursor; + } + }, + }); + lastSession = result.session; + timedOut = result.timedOut; + const { prInfo } = extractNewAssistantText(result.session, null); + const tail = composeStreamTail(result.session, prInfo); + if (tail) { + send({ + id: chatId, object: 'chat.completion.chunk', created, model: displayModel, + choices: [{ index: 0, delta: { content: tail }, finish_reason: null }], + }); + fullText += tail; + } + } catch (err) { + stopHeartbeat(); + const oa = devinErrorToOpenAI(err, displayModel); + sendError(oa.body?.error?.message || err.message, oa.body?.error?.type || 'upstream_error'); + return; + } + + // Final chunk + cache + const afterMessages = [...messages, { role: 'assistant', content: fullText }]; + cacheStore(fpDevin(callerKey, afterMessages), sessionId); + + send({ + id: chatId, object: 'chat.completion.chunk', created, model: displayModel, + choices: [{ index: 0, delta: {}, finish_reason: timedOut ? 'length' : 'stop' }], + usage: estimateUsage(messages, fullText), + x_devin: { + session_id: sessionId, + status: lastSession?.status_enum || lastSession?.status || '', + pull_request_url: lastSession?.pull_request?.url || null, + }, + }); + stopHeartbeat(); + sendDone(); + }, + }; +} + +function composeAssistantContent(text, prInfo, session) { + let body = text || ''; + const tail = composeStreamTail(session, prInfo); + if (tail) body += tail; + if (!body) body = '(Devin session produced no assistant message before timeout.)'; + return body; +} + +function composeStreamTail(session, prInfo) { + const parts = []; + if (prInfo?.url) parts.push(`\n\n---\nPull request: ${prInfo.url}`); + if (session?.status_enum && session.status_enum !== 'finished' && session.status_enum !== 'blocked') { + parts.push(`\n\n_(session status: ${session.status_enum})_`); + } + return parts.join(''); +} + +function buildOpenAIResponseBody({ chatId, created, displayModel, content, session, finishReason, usagePrompt, usageCompletion }) { + return { + id: chatId, + object: 'chat.completion', + created, + model: displayModel, + choices: [{ + index: 0, + message: { role: 'assistant', content }, + finish_reason: finishReason, + }], + usage: estimateUsage(usagePrompt, usageCompletion), + x_devin: { + session_id: session?.session_id || null, + status: session?.status_enum || session?.status || '', + pull_request_url: session?.pull_request?.url || null, + structured_output: session?.structured_output ?? null, + }, + }; +} + +/** + * Rough token estimate — Devin doesn't return token counts. We use the + * standard ~4-chars-per-token heuristic to fill OpenAI's required usage + * fields. Clients that need exact billing should use the Devin ACU + * surface, not this estimate. + */ +function estimateUsage(promptMessages, completionText) { + const promptChars = (Array.isArray(promptMessages) ? promptMessages : []) + .reduce((n, m) => n + (contentToText(m?.content)?.length || 0), 0); + const completionChars = completionText ? completionText.length : 0; + const promptTokens = Math.max(1, Math.round(promptChars / 4)); + const completionTokens = Math.max(0, Math.round(completionChars / 4)); + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }; +} + +function devinErrorToOpenAI(err, displayModel) { + if (err?.name === 'AbortError') { + return { status: 499, body: { error: { message: 'Client disconnected', type: 'aborted' } } }; + } + if (err instanceof DevinApiError) { + let type = 'upstream_error'; + if (err.status === 401 || err.status === 403) type = 'authentication_error'; + else if (err.status === 404) type = 'not_found_error'; + else if (err.status === 429) type = 'rate_limit_exceeded'; + else if (err.status === 503) type = 'service_unavailable'; + else if (err.status >= 500) type = 'upstream_server_error'; + else if (err.status >= 400 && err.status < 500) type = 'invalid_request'; + return { + status: err.status || 502, + body: { + error: { + message: `Devin (${displayModel}): ${err.message}`, + type, + code: err.status ? String(err.status) : null, + }, + }, + }; + } + log.error(`Devin handler unexpected error: ${err?.stack || err?.message || err}`); + return { + status: 500, + body: { error: { message: `Devin (${displayModel}): ${err?.message || 'unknown error'}`, type: 'internal_error' } }, + }; +} + +// Re-exports for tests. +export { messagesToPrompt, contentToText, tailUserMessage, extractNewAssistantText, extractNewAssistantMessages, lastEventId }; diff --git a/src/handlers/devin-passthrough.js b/src/handlers/devin-passthrough.js new file mode 100644 index 00000000..0242ed50 --- /dev/null +++ b/src/handlers/devin-passthrough.js @@ -0,0 +1,648 @@ +/** + * Devin Cloud REST passthrough — mounts every public Devin endpoint that + * isn't already wrapped by the OpenAI-shaped /v1/chat/completions adapter + * under `/v1/devin/*`, so callers can drive Devin's full toolchain + * (sessions / attachments / knowledge / playbooks / secrets / enterprise + * audit + consumption metrics) through a single endpoint without + * juggling two API keys client-side. + * + * Why this lives next to handlers/devin-chat.js + * ──────────────────────────────────────────── + * `handlers/devin-chat.js` adapts Devin → OpenAI: prompt → session, + * polling, SSE faking, etc. It only covers conversation. Production + * deployments also want to: + * • list / terminate / tag sessions (cleanup, dashboards) + * • upload context attachments before starting a session + * • CRUD org knowledge entries, playbooks, secrets + * • read consumption / audit logs at the enterprise level + * Reimplementing those would add zero value — Devin's REST schema is + * already stable. So this module pipes them through verbatim, only + * inserting the operator's `DEVIN_API_KEY` as `Authorization: Bearer …` + * and translating non-2xx into the same OpenAI-shaped error type + * `handleDevinChat` emits so a single client error handler covers both. + * + * Routing + * ─────── + * /v1/devin/ → https://api.devin.ai/ + * + * Three Devin API surfaces are exposed under this single mount: + * • v1 (legacy, org-scoped via apk_* key): routes that start with + * `/sessions`, `/attachments`, `/knowledge`, `/playbooks`, `/secrets` + * (the v1 prefix is implied — kept for back-compat with the original + * mount before v3 existed). + * • v3 (current, RBAC + service-user tokens): routes that explicitly + * start with `/v3/organizations/:org_id/...` or `/v3/enterprise/...`. + * Callers must provide the org id themselves; the proxy does not + * guess it from `DEVIN_API_KEY` because a single service-user token + * may be scoped to multiple orgs in enterprise deployments. + * • v2 (legacy enterprise, personal-key-only): routes under + * `/v2/enterprise/...` for billing, consumption metrics, audit logs, + * enterprise API key management. Kept because v3 hasn't fully + * replaced these surfaces yet (consumption-cycles in particular). + * Trailing query string and the HTTP method are preserved verbatim. + * + * Body shaping + * ──────────── + * • JSON requests are read, JSON-validated (we already require a + * `Content-Type: application/json` body for almost all endpoints), + * and re-serialised. This catches syntax errors before they reach + * Devin and returns a tidy 400 instead of an upstream 422. + * • Multipart `POST /v1/devin/attachments` is streamed through — + * we copy the raw request body + Content-Type header without + * buffering, so multi-megabyte uploads don't blow up memory. + * • `GET /v1/devin/attachments/:id/file` is also streamed, but Devin + * answers with a 302 to a presigned URL — we forward the redirect + * verbatim so the client downloads directly from the storage host + * and we never proxy the bytes. + * + * Auth surface + * ──────────── + * • The proxy's own `API_KEY` gate (validateApiKey in server.js) runs + * BEFORE this handler — callers must already have proven they're + * trusted to hit the proxy. This route only adds the `DEVIN_API_KEY` + * env value as the upstream bearer; we never accept a per-request + * Devin token from the caller (would defeat the point of the proxy). + * • If `DEVIN_API_KEY` isn't configured, every route returns 503 with + * the same shape `handleDevinChat` uses for the missing-key case. + * + * Endpoint allowlist + * ────────────────── + * Anything not in ALLOWED_ROUTES returns 404 so a typo doesn't silently + * pivot to an unintended Devin endpoint. The list mirrors the public + * Devin API surfaces (v1, v3 organization + enterprise, v2 enterprise) + * from docs.devin.ai/llms.txt. Adding a new endpoint requires an + * explicit entry here — silent passthrough of unknown routes would + * expose any future preview endpoint the proxy operator hasn't audited. + */ + +import { config, log } from '../config.js'; +import { _internals as _devinClientInternals } from '../devin-client.js'; + +const DEFAULT_DEVIN_BASE = 'https://api.devin.ai'; + +/** + * Allowlist of (METHOD, path-pattern) → upstream-path-template tuples. + * Path patterns use `:id` placeholders that capture a single segment + * (no slashes). Upstream templates substitute `${id}` back in. + * + * Keep this aligned with docs.devin.ai/llms.txt. New endpoints should be + * added explicitly — silent passthrough of unknown routes would expose + * any future preview endpoint the proxy operator hasn't audited. + */ +const ALLOWED_ROUTES = [ + // Sessions + ['POST', '/sessions', '/v1/sessions'], + ['GET', '/sessions', '/v1/sessions'], + ['GET', '/sessions/:id', '/v1/sessions/${id}'], + ['POST', '/sessions/:id/message', '/v1/sessions/${id}/message'], + ['DELETE', '/sessions/:id', '/v1/sessions/${id}'], + ['POST', '/sessions/:id/tags', '/v1/sessions/${id}/tags'], + ['PUT', '/sessions/:id/tags', '/v1/sessions/${id}/tags'], + + // Attachments + ['POST', '/attachments', '/v1/attachments'], + ['GET', '/attachments/:id/file', '/v1/attachments/${id}/file'], + + // Knowledge + ['GET', '/knowledge', '/v1/knowledge'], + ['POST', '/knowledge', '/v1/knowledge'], + ['PATCH', '/knowledge/:id', '/v1/knowledge/${id}'], + ['PUT', '/knowledge/:id', '/v1/knowledge/${id}'], + ['DELETE', '/knowledge/:id', '/v1/knowledge/${id}'], + + // Playbooks + ['GET', '/playbooks', '/v1/playbooks'], + ['GET', '/playbooks/:id', '/v1/playbooks/${id}'], + ['POST', '/playbooks', '/v1/playbooks'], + ['PATCH', '/playbooks/:id', '/v1/playbooks/${id}'], + ['PUT', '/playbooks/:id', '/v1/playbooks/${id}'], + ['DELETE', '/playbooks/:id', '/v1/playbooks/${id}'], + + // Secrets + ['GET', '/secrets', '/v1/secrets'], + ['GET', '/secrets/:id', '/v1/secrets/${id}'], + ['POST', '/secrets', '/v1/secrets'], + ['DELETE', '/secrets/:id', '/v1/secrets/${id}'], + + // ───────────────────────────────────────────────────────────────────── + // v3 — current Devin API (service-user tokens, RBAC, org-scoped). + // Callers must include `/v3/organizations//...` in the path; + // we never guess org_id from the API key because a single service-user + // token may be valid across multiple orgs in enterprise deployments. + // Static segments (e.g. /sessions/insights) MUST come before capture + // patterns (e.g. /sessions/:devin_id) so the matcher returns the + // intended row when both have the same segment count. + // ───────────────────────────────────────────────────────────────────── + + // v3 organizations — sessions (https://docs.devin.ai/api-reference/v3/sessions) + ['POST', '/v3/organizations/:org_id/sessions', '/v3/organizations/${org_id}/sessions'], + ['GET', '/v3/organizations/:org_id/sessions', '/v3/organizations/${org_id}/sessions'], + ['GET', '/v3/organizations/:org_id/sessions/insights', '/v3/organizations/${org_id}/sessions/insights'], + ['GET', '/v3/organizations/:org_id/sessions/:devin_id', '/v3/organizations/${org_id}/sessions/${devin_id}'], + ['DELETE', '/v3/organizations/:org_id/sessions/:devin_id', '/v3/organizations/${org_id}/sessions/${devin_id}'], + ['POST', '/v3/organizations/:org_id/sessions/:devin_id/messages', '/v3/organizations/${org_id}/sessions/${devin_id}/messages'], + ['POST', '/v3/organizations/:org_id/sessions/:devin_id/tags', '/v3/organizations/${org_id}/sessions/${devin_id}/tags'], + ['PUT', '/v3/organizations/:org_id/sessions/:devin_id/tags', '/v3/organizations/${org_id}/sessions/${devin_id}/tags'], + ['POST', '/v3/organizations/:org_id/sessions/:devin_id/archive', '/v3/organizations/${org_id}/sessions/${devin_id}/archive'], + ['POST', '/v3/organizations/:org_id/sessions/:devin_id/attachments', '/v3/organizations/${org_id}/sessions/${devin_id}/attachments'], + ['POST', '/v3/organizations/:org_id/sessions/:devin_id/insights/generate', '/v3/organizations/${org_id}/sessions/${devin_id}/insights/generate'], + + // v3 organizations — knowledge notes (org-scoped) + ['GET', '/v3/organizations/:org_id/knowledge/notes', '/v3/organizations/${org_id}/knowledge/notes'], + ['POST', '/v3/organizations/:org_id/knowledge/notes', '/v3/organizations/${org_id}/knowledge/notes'], + ['GET', '/v3/organizations/:org_id/knowledge/notes/:note_id', '/v3/organizations/${org_id}/knowledge/notes/${note_id}'], + ['PATCH', '/v3/organizations/:org_id/knowledge/notes/:note_id', '/v3/organizations/${org_id}/knowledge/notes/${note_id}'], + ['PUT', '/v3/organizations/:org_id/knowledge/notes/:note_id', '/v3/organizations/${org_id}/knowledge/notes/${note_id}'], + ['DELETE', '/v3/organizations/:org_id/knowledge/notes/:note_id', '/v3/organizations/${org_id}/knowledge/notes/${note_id}'], + + // v3 organizations — playbooks (org-scoped) + ['GET', '/v3/organizations/:org_id/playbooks', '/v3/organizations/${org_id}/playbooks'], + ['POST', '/v3/organizations/:org_id/playbooks', '/v3/organizations/${org_id}/playbooks'], + ['GET', '/v3/organizations/:org_id/playbooks/:playbook_id', '/v3/organizations/${org_id}/playbooks/${playbook_id}'], + ['PATCH', '/v3/organizations/:org_id/playbooks/:playbook_id', '/v3/organizations/${org_id}/playbooks/${playbook_id}'], + ['PUT', '/v3/organizations/:org_id/playbooks/:playbook_id', '/v3/organizations/${org_id}/playbooks/${playbook_id}'], + ['DELETE', '/v3/organizations/:org_id/playbooks/:playbook_id', '/v3/organizations/${org_id}/playbooks/${playbook_id}'], + + // v3 organizations — secrets (org-scoped) + ['GET', '/v3/organizations/:org_id/secrets', '/v3/organizations/${org_id}/secrets'], + ['GET', '/v3/organizations/:org_id/secrets/:secret_id', '/v3/organizations/${org_id}/secrets/${secret_id}'], + ['POST', '/v3/organizations/:org_id/secrets', '/v3/organizations/${org_id}/secrets'], + ['DELETE', '/v3/organizations/:org_id/secrets/:secret_id', '/v3/organizations/${org_id}/secrets/${secret_id}'], + + // v3 organizations — session messages (paginated, separate from the + // session detail endpoint). The chat adapter uses this internally when + // running against a v3 token; the passthrough exposes it for clients + // that want to drive Devin directly without the OpenAI wrapper. + ['GET', '/v3/organizations/:org_id/sessions/:devin_id/messages', '/v3/organizations/${org_id}/sessions/${devin_id}/messages'], + + // v3 organizations — attachments (org-scoped). The legacy v1 surface + // also exposes /v1/attachments without org scope; both routes are + // listed so callers can pick whichever matches their API key tier. + ['POST', '/v3/organizations/:org_id/attachments', '/v3/organizations/${org_id}/attachments'], + ['GET', '/v3/organizations/:org_id/attachments/:attachment_id/file', '/v3/organizations/${org_id}/attachments/${attachment_id}/file'], + + // v3 organizations — service users (only the org-level ones; the + // enterprise mint endpoint lives under /v3/enterprise/* below). + ['GET', '/v3/organizations/:org_id/service-users', '/v3/organizations/${org_id}/service-users'], + ['POST', '/v3/organizations/:org_id/service-users', '/v3/organizations/${org_id}/service-users'], + ['GET', '/v3/organizations/:org_id/service-users/:user_id', '/v3/organizations/${org_id}/service-users/${user_id}'], + ['DELETE', '/v3/organizations/:org_id/service-users/:user_id', '/v3/organizations/${org_id}/service-users/${user_id}'], + + // v3 organizations — users (read-only org membership directory) + ['GET', '/v3/organizations/:org_id/users', '/v3/organizations/${org_id}/users'], + ['GET', '/v3/organizations/:org_id/users/:user_id', '/v3/organizations/${org_id}/users/${user_id}'], + + // ───────────────────────────────────────────────────────────────────── + // v3 enterprise — cross-org operations gated on enterprise admin role. + // Sessions / knowledge / playbooks have org-equivalent endpoints above; + // enterprise variants let an admin operate across every org in their + // enterprise with a single token. + // ───────────────────────────────────────────────────────────────────── + + ['GET', '/v3/enterprise/sessions', '/v3/enterprise/sessions'], + ['GET', '/v3/enterprise/sessions/:devin_id', '/v3/enterprise/sessions/${devin_id}'], + ['DELETE', '/v3/enterprise/sessions/:devin_id', '/v3/enterprise/sessions/${devin_id}'], + ['POST', '/v3/enterprise/sessions/:devin_id/messages', '/v3/enterprise/sessions/${devin_id}/messages'], + ['POST', '/v3/enterprise/sessions/:devin_id/archive', '/v3/enterprise/sessions/${devin_id}/archive'], + ['POST', '/v3/enterprise/sessions/:devin_id/tags', '/v3/enterprise/sessions/${devin_id}/tags'], + ['PUT', '/v3/enterprise/sessions/:devin_id/tags', '/v3/enterprise/sessions/${devin_id}/tags'], + + // List the organizations under this enterprise (mentioned in the v3 + // migration guide as a current-API-only endpoint). Useful for ops + // dashboards that want to enumerate orgs without falling back to v2. + ['GET', '/v3/enterprise/organizations', '/v3/enterprise/organizations'], + + ['GET', '/v3/enterprise/knowledge/notes', '/v3/enterprise/knowledge/notes'], + ['POST', '/v3/enterprise/knowledge/notes', '/v3/enterprise/knowledge/notes'], + ['GET', '/v3/enterprise/knowledge/notes/:note_id', '/v3/enterprise/knowledge/notes/${note_id}'], + ['PATCH', '/v3/enterprise/knowledge/notes/:note_id', '/v3/enterprise/knowledge/notes/${note_id}'], + ['PUT', '/v3/enterprise/knowledge/notes/:note_id', '/v3/enterprise/knowledge/notes/${note_id}'], + ['DELETE', '/v3/enterprise/knowledge/notes/:note_id', '/v3/enterprise/knowledge/notes/${note_id}'], + + ['GET', '/v3/enterprise/playbooks', '/v3/enterprise/playbooks'], + ['POST', '/v3/enterprise/playbooks', '/v3/enterprise/playbooks'], + ['GET', '/v3/enterprise/playbooks/:playbook_id', '/v3/enterprise/playbooks/${playbook_id}'], + ['PATCH', '/v3/enterprise/playbooks/:playbook_id', '/v3/enterprise/playbooks/${playbook_id}'], + ['PUT', '/v3/enterprise/playbooks/:playbook_id', '/v3/enterprise/playbooks/${playbook_id}'], + ['DELETE', '/v3/enterprise/playbooks/:playbook_id', '/v3/enterprise/playbooks/${playbook_id}'], + + // ───────────────────────────────────────────────────────────────────── + // v2 enterprise — legacy admin surface (audit logs, consumption, + // billing, member management, enterprise API key provisioning). + // Kept whitelisted because v3 hasn't ported every endpoint yet; ops + // dashboards still need consumption-cycles for ACU budgeting. + // ───────────────────────────────────────────────────────────────────── + + // Audit + consumption (read-only) + ['GET', '/v2/enterprise/audit-logs', '/v2/enterprise/audit-logs'], + ['GET', '/v2/enterprise/consumption/cycles', '/v2/enterprise/consumption/cycles'], + ['GET', '/v2/enterprise/consumption/daily', '/v2/enterprise/consumption/daily'], + ['GET', '/v2/enterprise/consumption/user-daily', '/v2/enterprise/consumption/user-daily'], + ['GET', '/v2/enterprise/consumption/pr-metrics', '/v2/enterprise/consumption/pr-metrics'], + ['GET', '/v2/enterprise/consumption/searches-metrics', '/v2/enterprise/consumption/searches-metrics'], + ['GET', '/v2/enterprise/consumption/sessions-metrics', '/v2/enterprise/consumption/sessions-metrics'], + ['GET', '/v2/enterprise/consumption/usage-metrics', '/v2/enterprise/consumption/usage-metrics'], + + // API key management — provision / revoke single / revoke all + ['GET', '/v2/enterprise/api-keys', '/v2/enterprise/api-keys'], + ['POST', '/v2/enterprise/api-keys', '/v2/enterprise/api-keys'], + ['DELETE', '/v2/enterprise/api-keys', '/v2/enterprise/api-keys'], + ['DELETE', '/v2/enterprise/api-keys/:key_id', '/v2/enterprise/api-keys/${key_id}'], + + // Members + ['GET', '/v2/enterprise/members', '/v2/enterprise/members'], + ['POST', '/v2/enterprise/members/invite', '/v2/enterprise/members/invite'], + ['POST', '/v2/enterprise/members/roles/migrate', '/v2/enterprise/members/roles/migrate'], + ['PATCH', '/v2/enterprise/members/roles', '/v2/enterprise/members/roles'], + ['GET', '/v2/enterprise/members/organizations', '/v2/enterprise/members/organizations'], + ['GET', '/v2/enterprise/members/roles', '/v2/enterprise/members/roles'], + ['GET', '/v2/enterprise/members/:member_id', '/v2/enterprise/members/${member_id}'], + ['DELETE', '/v2/enterprise/members/:member_id', '/v2/enterprise/members/${member_id}'], + + // Organizations + IdP groups + ['GET', '/v2/enterprise/organizations', '/v2/enterprise/organizations'], + ['POST', '/v2/enterprise/organizations', '/v2/enterprise/organizations'], + ['GET', '/v2/enterprise/groups', '/v2/enterprise/groups'], + ['POST', '/v2/enterprise/groups', '/v2/enterprise/groups'], + ['GET', '/v2/enterprise/groups/:group_id', '/v2/enterprise/groups/${group_id}'], + + // Org group limits + ['GET', '/v2/enterprise/org-group-limits', '/v2/enterprise/org-group-limits'], + ['PATCH', '/v2/enterprise/org-group-limits', '/v2/enterprise/org-group-limits'], + + // VPC / infrastructure visibility + ['GET', '/v2/enterprise/infrastructure/hypervisors', '/v2/enterprise/infrastructure/hypervisors'], +]; + +/** + * Match `path` (already stripped of the `/v1/devin` prefix) and `method` + * against ALLOWED_ROUTES. + * + * Returns { upstreamPath, params } when matched, otherwise null. We try + * an exact match first (no params) so static routes ('/sessions') win + * over `:id` capture patterns ('/sessions/:id'). + */ +export function matchRoute(method, path) { + const upMethod = String(method || '').toUpperCase(); + for (const [m, pattern, template] of ALLOWED_ROUTES) { + if (m !== upMethod) continue; + const params = matchPattern(pattern, path); + if (!params) continue; + const upstreamPath = template.replace(/\$\{(\w+)\}/g, (_, k) => + encodeURIComponent(params[k] ?? ''), + ); + return { upstreamPath, params }; + } + return null; +} + +function matchPattern(pattern, path) { + const pp = pattern.split('/'); + const pa = path.split('/'); + if (pp.length !== pa.length) return null; + const params = {}; + for (let i = 0; i < pp.length; i++) { + if (pp[i].startsWith(':')) { + if (!pa[i]) return null; + params[pp[i].slice(1)] = decodeURIComponent(pa[i]); + } else if (pp[i] !== pa[i]) { + return null; + } + } + return params; +} + +/** + * Map a Node http.IncomingMessage onto the upstream Devin REST call and + * pipe the response back into `res`. + * + * Returns a Promise that resolves once the response is fully written. + * Errors from the upstream fetch are caught and translated into a 502 + * JSON body. Bytes are streamed for both directions — the JSON + * round-trip happens entirely inside Node's fetch implementation, but + * for binary endpoints (attachment download) we read the upstream + * body as a stream and pipe it without buffering. + */ +export async function handleDevinPassthrough(req, res) { + // Strip the /v1/devin prefix off the request URL so callers can use + // either `/v1/devin/sessions` or `/v1/devin/sessions/xyz?param=1`. + const url = new URL(req.url, 'http://x'); + const fullPath = url.pathname; + if (!fullPath.startsWith('/v1/devin')) { + return jsonError(res, 404, 'not_found', `Unknown path: ${fullPath}`); + } + let subPath = fullPath.slice('/v1/devin'.length) || '/'; + if (subPath !== '/' && subPath.endsWith('/')) subPath = subPath.slice(0, -1); + if (subPath === '/' || subPath === '') { + return jsonError(res, 404, 'not_found', 'Use /v1/devin/. See docs/devin-provider.md.'); + } + + // Proxy introspection — clients can poke this to find out which API + // version their key is bound to, what org_id is configured, and which + // routes are exposed. Doesn't proxy upstream; returns 503 if + // DEVIN_API_KEY isn't configured so the response shape matches every + // other Devin endpoint. + if (req.method === 'GET' && (subPath === '/_proxy/info' || subPath === '/_proxy/info/')) { + return handleProxyInfo(req, res); + } + if (req.method === 'GET' && (subPath === '/_proxy/routes' || subPath === '/_proxy/routes/')) { + return handleProxyRoutes(req, res); + } + + const match = matchRoute(req.method, subPath); + if (!match) { + return jsonError( + res, + 404, + 'not_found', + `No Devin passthrough route for ${req.method} /v1/devin${subPath}.`, + ); + } + + const apiKey = config.devinApiKey; + if (!apiKey) { + // Mirror the shape handleDevinChat uses so a single client-side + // error handler covers both surfaces. + return jsonError(res, 503, 'configuration_error', 'Devin provider is not configured (set DEVIN_API_KEY).'); + } + + const base = (config.devinApiBase || DEFAULT_DEVIN_BASE).replace(/\/+$/, ''); + const upstreamUrl = base + match.upstreamPath + (url.search || ''); + const contentType = String(req.headers['content-type'] || ''); + const isMultipart = contentType.toLowerCase().startsWith('multipart/'); + const isBinaryUpload = contentType && !contentType.startsWith('application/json') && !isMultipart && (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH'); + + const upstreamHeaders = { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': req.headers['accept'] || 'application/json', + }; + + let body; + try { + if (req.method === 'GET' || req.method === 'DELETE' || req.method === 'HEAD') { + body = undefined; + } else if (isMultipart || isBinaryUpload) { + // Stream the raw request body. Node's fetch accepts a ReadableStream + // here; we forward the original Content-Type / Content-Length so the + // remote multipart parser sees the same boundary the client sent. + upstreamHeaders['Content-Type'] = contentType; + if (req.headers['content-length']) { + upstreamHeaders['Content-Length'] = String(req.headers['content-length']); + } + body = nodeReqToWebStream(req); + } else { + // JSON path — buffer + re-serialize so we can return a clean 400 + // instead of a 422 from Devin's pydantic validator. + const raw = await readBodyAsString(req); + if (raw.length === 0) { + body = undefined; + } else { + try { + const parsed = JSON.parse(raw); + upstreamHeaders['Content-Type'] = 'application/json'; + body = JSON.stringify(parsed); + } catch (err) { + return jsonError(res, 400, 'invalid_request', `Invalid JSON in body: ${err.message}`); + } + } + } + } catch (err) { + if (err && err.statusCode) { + return jsonError(res, err.statusCode, 'invalid_request', err.message || 'Bad request'); + } + return jsonError(res, 400, 'invalid_request', err?.message || 'Bad request'); + } + + log.debug(`Devin passthrough: ${req.method} ${subPath} → ${match.upstreamPath}`); + + let upstreamRes; + try { + upstreamRes = await fetch(upstreamUrl, { + method: req.method, + headers: upstreamHeaders, + body, + // Some attachment downloads return 302 — let the client follow it + // (we forward the redirect rather than transparently following it, + // so presigned-URL handling stays in the caller's network). + redirect: 'manual', + // Node fetch needs duplex:'half' when uploading a stream. + duplex: body && typeof body !== 'string' ? 'half' : undefined, + }); + } catch (err) { + log.warn(`Devin passthrough fetch error: ${err.message}`); + return jsonError(res, 502, 'upstream_error', `Network error calling Devin API: ${err.message}`); + } + + // Forward status + selected response headers. We intentionally do NOT + // copy Set-Cookie / Server / etc — Devin doesn't use them today, and + // pass-through cookies would be a cross-tenant leak vector. + const responseHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }; + const passHeaders = ['content-type', 'content-length', 'location', 'content-disposition', 'etag']; + for (const h of passHeaders) { + const v = upstreamRes.headers.get(h); + if (v) responseHeaders[h.replace(/(^|-)([a-z])/g, (_, p, c) => p + c.toUpperCase())] = v; + } + res.writeHead(upstreamRes.status, responseHeaders); + + // Stream the body. For non-2xx responses we still stream the body + // verbatim — Devin returns JSON error details there and the client + // wants to see them. + if (!upstreamRes.body) { + res.end(); + return; + } + try { + // Web ReadableStream → Node Writable + const reader = upstreamRes.body.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value && !res.writableEnded) { + if (!res.write(Buffer.from(value))) { + await once(res, 'drain'); + } + } + } + } catch (err) { + log.warn(`Devin passthrough body stream error: ${err.message}`); + } finally { + if (!res.writableEnded) res.end(); + } +} + +/** + * GET /v1/devin/_proxy/info — introspection. + * + * Returns a self-description of the proxy's Devin configuration without + * leaking the actual API key. Useful for clients to know: + * - Whether the proxy is wired up at all (DEVIN_API_KEY present) + * - Which API version (`v1` | `v3`) the configured key speaks + * - Which org_id the operator configured (for v3 callers) + * - The current ACU model aliases the chat adapter supports + * + * When `?probe=1` is set, the handler issues a real round-trip to verify + * the key (a cheap GET against the v3 sessions list, falling back to v1). + * Without `probe=1` the handler returns the cached effective version + * (filled in by the chat adapter on first call) without any network I/O. + */ +async function handleProxyInfo(req, res) { + const apiKey = config.devinApiKey; + if (!apiKey) { + return jsonError(res, 503, 'configuration_error', + 'Devin provider is not configured (set DEVIN_API_KEY).'); + } + const url = new URL(req.url, 'http://x'); + const wantProbe = url.searchParams.get('probe') === '1'; + const base = (config.devinApiBase || DEFAULT_DEVIN_BASE).replace(/\/+$/, ''); + const masked = apiKey.length > 8 ? `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}` : '***'; + + const info = { + configured: true, + api_base: base, + api_version_setting: String(config.devinApiVersion || 'auto').toLowerCase(), + org_id: config.devinOrgId || null, + api_key_prefix: apiKey.slice(0, 4), + api_key_mask: masked, + cached_effective_version: + _devinClientInternals._autoVersionCache.get(apiKey) || null, + default_snapshot_id: config.devinDefaultSnapshotId || null, + default_playbook_id: config.devinDefaultPlaybookId || null, + poll_interval_ms: config.devinPollIntervalMs, + max_wait_ms: config.devinMaxWaitMs, + }; + + if (wantProbe) { + info.probe = await probeApiVersion(apiKey, base, info.org_id); + } + + const data = JSON.stringify(info, null, 2); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }); + res.end(data); +} + +/** + * Issue a small, idempotent probe against Devin to determine which API + * surface the configured key speaks. Doesn't mutate state — just lists + * sessions on each surface and reports which ones returned 2xx. + * + * The probe is deliberately small: a `?limit=1` list call on v3 (and on + * v1 if it's even worth checking). We do v3 first because that's the + * surface every new `cog_*` token speaks; if it succeeds, we don't need + * to bother with v1. + */ +async function probeApiVersion(apiKey, base, orgId) { + const probe = { v1: null, v3: null }; + const headers = { 'Authorization': `Bearer ${apiKey}`, 'Accept': 'application/json' }; + // v1 probe + try { + const r = await fetch(`${base}/v1/sessions?limit=1`, { method: 'GET', headers }); + probe.v1 = { status: r.status, ok: r.ok }; + } catch (e) { + probe.v1 = { status: 0, ok: false, error: String(e.message || e) }; + } + // v3 probe (only if org_id is known — otherwise we can't form the path) + if (orgId) { + try { + const r = await fetch( + `${base}/v3/organizations/${encodeURIComponent(orgId)}/sessions?limit=1`, + { method: 'GET', headers }, + ); + probe.v3 = { status: r.status, ok: r.ok }; + } catch (e) { + probe.v3 = { status: 0, ok: false, error: String(e.message || e) }; + } + } else { + probe.v3 = { status: 0, ok: false, error: 'DEVIN_ORG_ID not configured' }; + } + let effective = null; + if (probe.v1?.ok) effective = 'v1'; + if (probe.v3?.ok) effective = 'v3'; + return { ...probe, effective }; +} + +/** + * GET /v1/devin/_proxy/routes — return the static allowlist as JSON. + * + * Lets clients enumerate the supported routes without scraping the + * source. The result is shaped for human readability: + * [{method, pattern, upstream}, ...] + */ +function handleProxyRoutes(req, res) { + const rows = ALLOWED_ROUTES.map(([method, pattern, upstream]) => ({ + method, pattern, upstream, + })); + const data = JSON.stringify({ count: rows.length, routes: rows }, null, 2); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }); + res.end(data); +} + +function jsonError(res, status, type, message) { + const data = JSON.stringify({ error: { message, type } }); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }); + res.end(data); +} + +function readBodyAsString(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + const MAX = 10 * 1024 * 1024; + req.on('data', (c) => { + size += c.length; + if (size > MAX) { + req.destroy(); + reject(Object.assign(new Error('Request body too large'), { statusCode: 413 })); + return; + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', reject); + }); +} + +/** + * Wrap a Node IncomingMessage as a Web ReadableStream so the global fetch + * can stream it upstream. Used for multipart/binary uploads where + * buffering the entire payload would defeat the point of streaming. + */ +function nodeReqToWebStream(req) { + return new ReadableStream({ + start(controller) { + req.on('data', (chunk) => { + try { controller.enqueue(new Uint8Array(chunk)); } catch { /* closed */ } + }); + req.on('end', () => { + try { controller.close(); } catch { /* closed */ } + }); + req.on('error', (err) => { + try { controller.error(err); } catch { /* closed */ } + }); + }, + cancel() { + try { req.destroy(); } catch { /* already destroyed */ } + }, + }); +} + +function once(emitter, event) { + return new Promise((resolve) => emitter.once(event, resolve)); +} + +// Test helpers — keep at the bottom so production callers don't import them by accident. +export { ALLOWED_ROUTES }; diff --git a/src/models.js b/src/models.js index d76b6def..f493fbfd 100644 --- a/src/models.js +++ b/src/models.js @@ -211,6 +211,32 @@ export const MODELS = { 'adaptive': { name: 'adaptive', provider: 'windsurf', enumValue: 0, modelUid: 'adaptive', credit: 1, deprecated: true }, 'arena-fast': { name: 'arena-fast', provider: 'windsurf', enumValue: 0, modelUid: 'arena-fast', credit: 0.5, deprecated: true }, 'arena-smart': { name: 'arena-smart', provider: 'windsurf', enumValue: 0, modelUid: 'arena-smart', credit: 1, deprecated: true }, + + // ── Devin Sessions (Cognition official API) ───────────── + // Routed via handlers/devin-chat.js — does NOT touch the Windsurf + // account pool or Language Server. Enabled when DEVIN_API_KEY is set. + // `provider: 'devin-sessions'` is checked by handleChatCompletions to + // short-circuit into the Devin adapter. `credit: 0` means these models + // don't participate in Windsurf ACU bookkeeping (they bill against + // your Devin org ACU budget instead). + // + // The five tiered aliases (low / medium / high / xhigh / max) mirror + // the standard EFFORT_LADDER pattern used elsewhere in the catalog, + // so clients written against Anthropic / OpenAI tier conventions can + // pick a Devin budget without learning a Devin-specific vocabulary. + // `devin-fast` and `devin-deep` stay as user-visible synonyms for the + // medium / xhigh tiers respectively (preserves the v2.0.95 model + // names) — the alias map below points them at the same entries. + 'devin': { name: 'devin', provider: 'devin-sessions', enumValue: 0, credit: 0 }, + 'devin-low': { name: 'devin-low', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 2 }, + 'devin-medium': { name: 'devin-medium', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 5 }, + 'devin-high': { name: 'devin-high', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 20 }, + 'devin-xhigh': { name: 'devin-xhigh', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 50 }, + 'devin-max': { name: 'devin-max', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 100 }, + // Pre-existing aliases — kept verbatim so existing callers continue + // to work; they map to the matching tier under the hood. + 'devin-fast': { name: 'devin-fast', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 5 }, + 'devin-deep': { name: 'devin-deep', provider: 'devin-sessions', enumValue: 0, credit: 0, devinMaxAcu: 50 }, }; // Build reverse lookup @@ -406,15 +432,63 @@ const CURSOR_ALIASES = { }; for (const [k, v] of Object.entries(CURSOR_ALIASES)) _lookup.set(k, v); -/** Resolve user model name → internal model key. */ +// Dynamic `devin-acu-` parser. Lets callers pick an arbitrary ACU +// budget without hard-coding every value in the catalog. Returns +// { key, maxAcu } // valid +// null // not a Devin ACU alias +// The integer is clamped to [1, 10_000] — above 10k Devin will reject +// the create-session anyway, but the clamp keeps surrounding code from +// having to validate at every call site. +export function parseDevinAcuAlias(name) { + if (!name || typeof name !== 'string') return null; + const m = /^devin-acu-(\d{1,5})$/i.exec(name.trim()); + if (!m) return null; + let n = parseInt(m[1], 10); + if (!Number.isFinite(n) || n <= 0) return null; + if (n > 10000) n = 10000; + return { key: `devin-acu-${n}`, maxAcu: n }; +} + +/** Resolve user model name → internal model key. + * + * Three paths: + * 1. Exact / lowercase match against the static alias table. + * 2. `devin-acu-` dynamic alias (no catalog entry; synthesised by + * getModelInfo). + * 3. Fallback: return the input verbatim so the rest of the pipeline + * can still log the requested model. + */ export function resolveModel(name) { if (!name) return null; - return _lookup.get(name) || _lookup.get(name.toLowerCase()) || name; + const hit = _lookup.get(name) || _lookup.get(name.toLowerCase()); + if (hit) return hit; + const dyn = parseDevinAcuAlias(name); + if (dyn) return dyn.key; + return name; } -/** Get model info including enum and uid. */ +/** Get model info including enum and uid. + * + * Returns a synthesised entry for `devin-acu-` so the rest of the + * proxy can treat dynamic ACU aliases as first-class Devin models + * (provider==='devin-sessions' → routed via handleDevinChat). The + * synthesised entry isn't shared between calls — it's a fresh literal + * per lookup so callers can't mutate the catalog. + */ export function getModelInfo(id) { - return MODELS[id] || null; + if (MODELS[id]) return MODELS[id]; + const dyn = parseDevinAcuAlias(id); + if (dyn) { + return { + name: dyn.key, + provider: 'devin-sessions', + enumValue: 0, + credit: 0, + devinMaxAcu: dyn.maxAcu, + synthetic: true, + }; + } + return null; } // v2.0.84 (#118 0a00) — when an entire account pool is rate-limited @@ -548,11 +622,23 @@ export function getTierModels(tier) { return MODEL_TIER_ACCESS[tier] || MODEL_TIER_ACCESS.unknown; } -/** List all models in OpenAI /v1/models format. Hides deprecated models. */ +/** List all models in OpenAI /v1/models format. Hides deprecated models. + * Devin-sessions models are only listed when DEVIN_API_KEY is configured, + * so clients that don't have a Devin key don't see entries that will only + * ever 503. Lazy-imported to avoid a config.js cycle when models.js is + * loaded very early. */ export function listModels() { const ts = Math.floor(Date.now() / 1000); + // eslint-disable-next-line global-require + let devinConfigured = false; + try { + // dynamic import avoided to keep this synchronous; the env var is the + // source of truth and is loaded before any model listing happens. + devinConfigured = !!process.env.DEVIN_API_KEY; + } catch {} return Object.entries(MODELS) .filter(([, info]) => !info.deprecated) + .filter(([, info]) => info.provider !== 'devin-sessions' || devinConfigured) .map(([id, info]) => ({ id: info.name, object: 'model', diff --git a/src/server.js b/src/server.js index 78d44102..f8c9dac6 100644 --- a/src/server.js +++ b/src/server.js @@ -31,6 +31,19 @@ import { setAccountProxy } from './dashboard/proxy-config.js'; import { config, log } from './config.js'; import { VERSION } from './version.js'; import { callerKeyFromRequest } from './caller-key.js'; +import { resolveModel, getModelInfo } from './models.js'; +import { handleDevinPassthrough } from './handlers/devin-passthrough.js'; + +// Devin-sessions models don't use the Windsurf account pool — they +// talk to api.devin.ai directly. When such a model is requested, skip +// the `isAuthenticated()` gate (which checks Windsurf account state) +// and let handleDevinChat enforce the DEVIN_API_KEY requirement. +function isDevinSessionModel(modelName) { + if (!modelName) return false; + const key = resolveModel(modelName); + const info = key ? getModelInfo(key) : null; + return info?.provider === 'devin-sessions'; +} const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..'); @@ -83,8 +96,8 @@ function json(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, x-devin-session-id, anthropic-version', // Per-request dynamic responses must not be cached by intermediaries. // Some upstream aggregators (e.g. sub2api, #97) priority-cache responses // when they don't see an explicit Cache-Control directive and serve @@ -101,8 +114,8 @@ async function route(req, res) { if (method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, x-devin-session-id', }); return res.end(); } @@ -315,13 +328,18 @@ async function route(req, res) { return json(res, 200, handleModels()); } - if (path === '/v1/chat/completions' && method === 'POST') { - if (!isAuthenticated()) { - return json(res, 503, { - error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'auth_error' }, - }); - } + // ── Devin Cloud REST passthrough ─────────────────────── + // Forwards every Devin endpoint that isn't wrapped by /v1/chat/completions + // (sessions list / terminate / tags, attachments, knowledge, playbooks, + // secrets) to api.devin.ai with the operator's DEVIN_API_KEY. The + // proxy's own API_KEY gate (above) still applies, so callers must be + // authenticated to the proxy. See handlers/devin-passthrough.js for the + // route allowlist and the streaming pipeline. + if (path.startsWith('/v1/devin/') || path === '/v1/devin') { + return handleDevinPassthrough(req, res); + } + if (path === '/v1/chat/completions' && method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request' } }); @@ -332,9 +350,16 @@ async function route(req, res) { if (body.messages.length === 0) { return json(res, 400, { error: { message: 'messages must contain at least 1 item', type: 'invalid_request' } }); } + // Windsurf account gate — bypassed for Devin-sessions models which + // have their own DEVIN_API_KEY check inside handleDevinChat. + if (!isDevinSessionModel(body.model) && !isAuthenticated()) { + return json(res, 503, { + error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'auth_error' }, + }); + } const reqStartedAt = Date.now(); - const result = await handleChatCompletions(body, { callerKey: callerKeyFromRequest(req, extractToken(req), body) }); + const result = await handleChatCompletions(body, { callerKey: callerKeyFromRequest(req, extractToken(req), body), headers: req.headers }); const processingMs = Date.now() - reqStartedAt; const modelHeaders = { 'x-request-id': 'req-' + randomUUID(), @@ -369,12 +394,6 @@ async function route(req, res) { } if (path === '/v1/responses' && method === 'POST') { - if (!isAuthenticated()) { - return json(res, 503, { - error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'auth_error' }, - }); - } - let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request' } }); @@ -382,9 +401,14 @@ async function route(req, res) { if (body.input == null) { return json(res, 400, { error: { message: 'input is required', type: 'invalid_request' } }); } + if (!isDevinSessionModel(body.model) && !isAuthenticated()) { + return json(res, 503, { + error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'auth_error' }, + }); + } const reqStartedAt = Date.now(); - const result = await handleResponses(body, { context: { callerKey: callerKeyFromRequest(req, extractToken(req), body) } }); + const result = await handleResponses(body, { context: { callerKey: callerKeyFromRequest(req, extractToken(req), body), headers: req.headers } }); const processingMs = Date.now() - reqStartedAt; const modelHeaders = { 'x-request-id': 'req-' + randomUUID(), @@ -408,9 +432,6 @@ async function route(req, res) { // Anthropic Messages API — Claude Code compatibility if (path === '/v1/messages' && method === 'POST') { - if (!isAuthenticated()) { - return json(res, 503, { type: 'error', error: { type: 'api_error', message: 'No active accounts' } }); - } let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON' } }); @@ -418,7 +439,10 @@ async function route(req, res) { if (!Array.isArray(body.messages) || body.messages.length === 0) { return json(res, 400, { type: 'error', error: { type: 'invalid_request_error', message: 'messages must be a non-empty array' } }); } - const result = await handleMessages(body, { callerKey: callerKeyFromRequest(req, extractToken(req), body) }); + if (!isDevinSessionModel(body.model) && !isAuthenticated()) { + return json(res, 503, { type: 'error', error: { type: 'api_error', message: 'No active accounts' } }); + } + const result = await handleMessages(body, { callerKey: callerKeyFromRequest(req, extractToken(req), body), headers: req.headers }); const anthropicHeaders = { 'request-id': 'req-' + randomUUID(), 'anthropic-model': body.model || '', diff --git a/test/devin-adapter.test.js b/test/devin-adapter.test.js new file mode 100644 index 00000000..75a6c84f --- /dev/null +++ b/test/devin-adapter.test.js @@ -0,0 +1,656 @@ +/** + * Tests for the Devin Sessions provider adapter. + * + * Strategy: install a global fetch stub for the duration of each test so + * we exercise the full adapter (client + session cache + handler) without + * touching the network or the Windsurf account pool. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { resolveModel, getModelInfo, parseDevinAcuAlias } from '../src/models.js'; +import { clear as cacheClear, fingerprint, lookup, store, size as cacheSize } from '../src/devin-session-cache.js'; +import { createSession, getSession, sendMessage, pollUntilTerminal, DevinApiError, TERMINAL_STATUSES } from '../src/devin-client.js'; +import { messagesToPrompt, contentToText, tailUserMessage, extractNewAssistantMessages, lastEventId, handleDevinChat } from '../src/handlers/devin-chat.js'; +import { config } from '../src/config.js'; + +// Capture original env, config snapshot, and global fetch for restore. +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; +const originalDevinConfig = { + devinApiKey: config.devinApiKey, + devinApiBase: config.devinApiBase, + devinPollIntervalMs: config.devinPollIntervalMs, + devinMaxWaitMs: config.devinMaxWaitMs, + devinDefaultSnapshotId: config.devinDefaultSnapshotId, + devinDefaultPlaybookId: config.devinDefaultPlaybookId, + devinSessionCacheTtlMs: config.devinSessionCacheTtlMs, + devinSessionCacheMaxEntries: config.devinSessionCacheMaxEntries, +}; + +function installFetchStub(routes) { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + const u = typeof url === 'string' ? url : url.url; + const method = (init.method || 'GET').toUpperCase(); + const key = `${method} ${new URL(u).pathname}`; + calls.push({ url: u, method, init, key }); + const handler = routes[key]; + if (!handler) { + return new Response(JSON.stringify({ detail: `No stub for ${key}` }), { status: 404 }); + } + return handler({ url: u, init, calls }); + }; + return calls; +} + +function restoreFetch() { globalThis.fetch = originalFetch; } + +beforeEach(() => { + cacheClear(); + // config is built at module load, so mutate fields directly (they're plain props). + config.devinApiKey = 'apk_test_key'; + config.devinApiBase = 'https://api.devin.ai'; + config.devinPollIntervalMs = 5; + config.devinMaxWaitMs = 500; + config.devinDefaultSnapshotId = ''; + config.devinDefaultPlaybookId = ''; + config.devinSessionCacheTtlMs = 60 * 60 * 1000; + config.devinSessionCacheMaxEntries = 1000; +}); +afterEach(() => { + process.env = originalEnv; + Object.assign(config, originalDevinConfig); + restoreFetch(); + cacheClear(); +}); + +describe('Devin model registration', () => { + it('registers devin / devin-fast / devin-deep with provider=devin-sessions', () => { + for (const name of ['devin', 'devin-fast', 'devin-deep']) { + assert.equal(resolveModel(name), name); + const info = getModelInfo(name); + assert.ok(info, `${name} is missing from MODELS`); + assert.equal(info.provider, 'devin-sessions'); + } + }); + + it('devin-fast and devin-deep carry distinct max_acu_limit hints', () => { + assert.equal(getModelInfo('devin').devinMaxAcu, undefined); + assert.equal(getModelInfo('devin-fast').devinMaxAcu, 5); + assert.equal(getModelInfo('devin-deep').devinMaxAcu, 50); + }); + + it('registers tiered aliases low/medium/high/xhigh/max with the expected ACU budgets', () => { + const tiers = [ + ['devin-low', 2], + ['devin-medium', 5], + ['devin-high', 20], + ['devin-xhigh', 50], + ['devin-max', 100], + ]; + for (const [name, expected] of tiers) { + assert.equal(resolveModel(name), name); + const info = getModelInfo(name); + assert.ok(info, `${name} is missing from MODELS`); + assert.equal(info.provider, 'devin-sessions'); + assert.equal(info.devinMaxAcu, expected); + } + }); + + it('parseDevinAcuAlias accepts devin-acu- with clamping and rejects garbage', () => { + assert.deepEqual(parseDevinAcuAlias('devin-acu-7'), { key: 'devin-acu-7', maxAcu: 7 }); + assert.deepEqual(parseDevinAcuAlias('DEVIN-ACU-30'), { key: 'devin-acu-30', maxAcu: 30 }); + assert.deepEqual(parseDevinAcuAlias('devin-acu-99999'), { key: 'devin-acu-10000', maxAcu: 10000 }); + assert.equal(parseDevinAcuAlias('devin-acu-0'), null); + assert.equal(parseDevinAcuAlias('devin-acu-abc'), null); + assert.equal(parseDevinAcuAlias('devin-acu-'), null); + assert.equal(parseDevinAcuAlias('devin'), null); + assert.equal(parseDevinAcuAlias(null), null); + }); + + it('resolveModel synthesises devin-acu- entries via getModelInfo', () => { + assert.equal(resolveModel('devin-acu-12'), 'devin-acu-12'); + const info = getModelInfo('devin-acu-12'); + assert.ok(info); + assert.equal(info.provider, 'devin-sessions'); + assert.equal(info.devinMaxAcu, 12); + assert.equal(info.synthetic, true); + // The synthetic entry is NOT installed into the static MODELS map — + // a second lookup re-synthesises a fresh object so accidental + // mutation can't poison the catalog. + const second = getModelInfo('devin-acu-12'); + assert.notStrictEqual(info, second); + assert.equal(second.devinMaxAcu, 12); + }); + + it('resolveModel returns the raw string for non-devin garbage so logs still show what the client sent', () => { + assert.equal(resolveModel('not-a-real-model'), 'not-a-real-model'); + assert.equal(getModelInfo('not-a-real-model'), null); + }); +}); + +describe('messagesToPrompt / contentToText', () => { + it('preserves role boundaries and labels system messages', () => { + const prompt = messagesToPrompt([ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'user', content: 'fix bug' }, + ]); + assert.match(prompt, /\nbe helpful\n<\/system>/); + assert.match(prompt, /\nhello\n<\/user>/); + assert.match(prompt, /\nhi\n<\/assistant>/); + assert.match(prompt, /\nfix bug\n<\/user>/); + }); + + it('coerces multimodal user content to text + image placeholders', () => { + const text = contentToText([ + { type: 'text', text: 'caption this' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ]); + assert.match(text, /caption this/); + assert.match(text, /\[image: https:\/\/example\.com\/cat\.png\]/); + }); + + it('tailUserMessage returns null when last is not user', () => { + assert.equal(tailUserMessage([{ role: 'user', content: 'a' }, { role: 'assistant', content: 'b' }]), null); + assert.equal(tailUserMessage([{ role: 'user', content: 'hello' }]), 'hello'); + }); +}); + +describe('devin-session-cache', () => { + it('round-trips a fingerprint → session id', () => { + const fp = fingerprint('caller', [{ role: 'user', content: 'a' }]); + assert.equal(lookup(fp), null); + store(fp, 'devin-session-abc'); + assert.equal(lookup(fp), 'devin-session-abc'); + }); + + it('different caller keys produce different fingerprints', () => { + const msgs = [{ role: 'user', content: 'same' }]; + assert.notEqual(fingerprint('a', msgs), fingerprint('b', msgs)); + }); + + it('normalizes array content to stable text', () => { + const a = fingerprint('c', [{ role: 'user', content: 'hi' }]); + const b = fingerprint('c', [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]); + assert.equal(a, b); + }); + + it('clear empties the cache', () => { + store('x', 'y'); + assert.ok(cacheSize() >= 1); + cacheClear(); + assert.equal(cacheSize(), 0); + }); +}); + +describe('devin-client', () => { + it('createSession posts prompt and forwards optional params', async () => { + const calls = installFetchStub({ + 'POST /v1/sessions': async ({ init }) => { + const body = JSON.parse(init.body); + assert.equal(body.prompt, 'do the thing'); + assert.equal(body.max_acu_limit, 5); + assert.equal(body.snapshot_id, 'snap_x'); + return new Response(JSON.stringify({ session_id: 'devin-1', url: 'https://app/...', is_new_session: true }), { status: 200 }); + }, + }); + const out = await createSession({ prompt: 'do the thing', max_acu_limit: 5, snapshot_id: 'snap_x' }); + assert.equal(out.session_id, 'devin-1'); + assert.equal(calls[0].init.headers.Authorization, 'Bearer apk_test_key'); + }); + + it('createSession throws DevinApiError on 401 with API detail', async () => { + installFetchStub({ + 'POST /v1/sessions': async () => new Response(JSON.stringify({ detail: 'bad key' }), { status: 401 }), + }); + await assert.rejects( + () => createSession({ prompt: 'x' }), + (err) => err instanceof DevinApiError && err.status === 401 && /bad key/.test(err.message), + ); + }); + + it('getSession returns parsed payload', async () => { + installFetchStub({ + 'GET /v1/sessions/devin-1': async () => new Response(JSON.stringify({ + session_id: 'devin-1', status: 'finished', status_enum: 'finished', + messages: [{ type: 'devin_message', message: 'done', event_id: 'e1', timestamp: 't' }], + }), { status: 200 }), + }); + const s = await getSession('devin-1'); + assert.equal(s.status_enum, 'finished'); + assert.equal(s.messages.length, 1); + }); + + it('sendMessage posts message body', async () => { + let captured = null; + installFetchStub({ + 'POST /v1/sessions/devin-1/message': async ({ init }) => { + captured = JSON.parse(init.body); + return new Response('', { status: 200 }); + }, + }); + const res = await sendMessage('devin-1', 'follow-up'); + assert.equal(captured.message, 'follow-up'); + // empty body → null + assert.equal(res, null); + }); + + it('pollUntilTerminal polls until status_enum is terminal', async () => { + let getCount = 0; + installFetchStub({ + 'GET /v1/sessions/devin-1': async () => { + getCount++; + const status = getCount < 3 ? 'working' : 'finished'; + return new Response(JSON.stringify({ + session_id: 'devin-1', status, status_enum: status, + messages: [{ type: 'devin_message', message: `m${getCount}`, event_id: `e${getCount}`, timestamp: 't' }], + }), { status: 200 }); + }, + }); + const { session, timedOut } = await pollUntilTerminal('devin-1', { intervalMs: 5, maxWaitMs: 1000 }); + assert.equal(timedOut, false); + assert.equal(session.status_enum, 'finished'); + assert.ok(TERMINAL_STATUSES.has(session.status_enum)); + assert.ok(getCount >= 3, 'should have polled at least 3 times'); + }); + + it('pollUntilTerminal honours maxWaitMs and reports timedOut', async () => { + installFetchStub({ + 'GET /v1/sessions/devin-1': async () => new Response(JSON.stringify({ + session_id: 'devin-1', status: 'working', status_enum: 'working', messages: [], + }), { status: 200 }), + }); + const { timedOut } = await pollUntilTerminal('devin-1', { intervalMs: 5, maxWaitMs: 30 }); + assert.equal(timedOut, true); + }); +}); + +describe('extractNewAssistantMessages', () => { + const session = { + messages: [ + { type: 'user_message', event_id: 'u1', message: 'go' }, + { type: 'devin_message', event_id: 'd1', message: 'starting' }, + { type: 'devin_message', event_id: 'd2', message: 'working' }, + ], + }; + + it('returns all assistant messages when cursor is null', () => { + const { messages, newSinceEventId } = extractNewAssistantMessages(session, null); + assert.deepEqual(messages, ['starting', 'working']); + assert.equal(newSinceEventId, 'd2'); + }); + + it('returns only messages after cursor', () => { + const { messages, newSinceEventId } = extractNewAssistantMessages(session, 'd1'); + assert.deepEqual(messages, ['working']); + assert.equal(newSinceEventId, 'd2'); + }); + + it('falls back to last-user-message boundary when cursor is unknown', () => { + const { messages } = extractNewAssistantMessages(session, 'event-that-was-pruned'); + assert.deepEqual(messages, ['starting', 'working']); + }); + + it('lastEventId returns trailing event_id', () => { + assert.equal(lastEventId(session), 'd2'); + assert.equal(lastEventId({ messages: [] }), null); + assert.equal(lastEventId(null), null); + }); +}); + +describe('handleDevinChat (end-to-end, mocked fetch)', () => { + it('refuses when DEVIN_API_KEY is missing', async () => { + config.devinApiKey = ''; + const result = await handleDevinChat({ model: 'devin', messages: [{ role: 'user', content: 'hi' }] }); + assert.equal(result.status, 503); + assert.match(result.body.error.message, /DEVIN_API_KEY/); + }); + + it('creates a new session and returns aggregated assistant content', async () => { + let createdParams = null; + installFetchStub({ + 'POST /v1/sessions': async ({ init }) => { + createdParams = JSON.parse(init.body); + return new Response(JSON.stringify({ session_id: 'devin-1', url: 'https://app/...', is_new_session: true }), { status: 200 }); + }, + 'GET /v1/sessions/devin-1': async () => new Response(JSON.stringify({ + session_id: 'devin-1', + status: 'finished', + status_enum: 'finished', + messages: [ + { type: 'user_message', event_id: 'u1', message: 'fix it', timestamp: 't' }, + { type: 'devin_message', event_id: 'd1', message: 'fixed', timestamp: 't' }, + ], + pull_request: { url: 'https://github.com/o/r/pull/1' }, + }), { status: 200 }), + }); + const result = await handleDevinChat({ model: 'devin-fast', messages: [ + { role: 'system', content: 'be terse' }, + { role: 'user', content: 'fix it' }, + ]}); + assert.equal(result.status, 200); + assert.equal(result.body.choices[0].message.role, 'assistant'); + assert.match(result.body.choices[0].message.content, /fixed/); + assert.match(result.body.choices[0].message.content, /github\.com\/o\/r\/pull\/1/); + assert.equal(result.body.choices[0].finish_reason, 'stop'); + assert.equal(result.headers['x-devin-session-id'], 'devin-1'); + assert.equal(result.body.x_devin.session_id, 'devin-1'); + // devin-fast should set max_acu_limit=5 on session creation + assert.equal(createdParams.max_acu_limit, 5); + // System message should be baked into the prompt + assert.match(createdParams.prompt, /\s*be terse/); + }); + + it('routes devin-acu- dynamically and applies the parsed ACU budget', async () => { + let createdParams = null; + installFetchStub({ + 'POST /v1/sessions': async ({ init }) => { + createdParams = JSON.parse(init.body); + return new Response(JSON.stringify({ session_id: 'devin-acu', is_new_session: true }), { status: 200 }); + }, + 'GET /v1/sessions/devin-acu': async () => new Response(JSON.stringify({ + session_id: 'devin-acu', + status: 'finished', status_enum: 'finished', + messages: [ + { type: 'user_message', event_id: 'u1', message: 'go', timestamp: 't' }, + { type: 'devin_message', event_id: 'd1', message: 'done', timestamp: 't' }, + ], + }), { status: 200 }), + }); + const result = await handleDevinChat({ model: 'devin-acu-17', messages: [{ role: 'user', content: 'go' }] }); + assert.equal(result.status, 200); + assert.equal(createdParams.max_acu_limit, 17); + }); + + it('plumbs metadata.devin_knowledge_ids / devin_tags / devin_secret_ids onto session create', async () => { + let createdParams = null; + installFetchStub({ + 'POST /v1/sessions': async ({ init }) => { + createdParams = JSON.parse(init.body); + return new Response(JSON.stringify({ session_id: 'devin-meta', is_new_session: true }), { status: 200 }); + }, + 'GET /v1/sessions/devin-meta': async () => new Response(JSON.stringify({ + session_id: 'devin-meta', status: 'finished', status_enum: 'finished', + messages: [ + { type: 'user_message', event_id: 'u1', message: 'go', timestamp: 't' }, + { type: 'devin_message', event_id: 'd1', message: 'ok', timestamp: 't' }, + ], + }), { status: 200 }), + }); + const result = await handleDevinChat({ + model: 'devin-low', + messages: [{ role: 'user', content: 'go' }], + metadata: { + devin_knowledge_ids: ['kn-1', 'kn-2', ''], + devin_secret_ids: ['sec-1'], + devin_tags: ['triage', 'demo'], + devin_unlisted: true, + devin_idempotent: true, + }, + }); + assert.equal(result.status, 200); + assert.deepEqual(createdParams.knowledge_ids, ['kn-1', 'kn-2']); // empty string dropped + assert.deepEqual(createdParams.secret_ids, ['sec-1']); + assert.deepEqual(createdParams.tags, ['triage', 'demo']); + assert.equal(createdParams.unlisted, true); + assert.equal(createdParams.idempotent, true); + // devin-low contributes max_acu_limit=2 + assert.equal(createdParams.max_acu_limit, 2); + }); + + it('reuses a cached session on a follow-up turn via fingerprint', async () => { + // Pre-seed the cache as if a prior turn had completed. + const prior = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello!' }, + ]; + store(fingerprint('', prior), 'devin-existing'); + + let createCalls = 0; + let messageBody = null; + installFetchStub({ + 'POST /v1/sessions': async () => { createCalls++; return new Response('{}', { status: 200 }); }, + 'GET /v1/sessions/devin-existing': async ({ calls }) => { + // First GET: snapshot for cursor. Second+: poll loop. + const polls = calls.filter(c => c.key === 'GET /v1/sessions/devin-existing').length; + const status = polls < 2 ? 'working' : 'finished'; + return new Response(JSON.stringify({ + session_id: 'devin-existing', + status, status_enum: status, + messages: polls < 2 ? [ + { type: 'user_message', event_id: 'u1', message: 'hi', timestamp: 't' }, + { type: 'devin_message', event_id: 'd1', message: 'hello!', timestamp: 't' }, + ] : [ + { type: 'user_message', event_id: 'u1', message: 'hi', timestamp: 't' }, + { type: 'devin_message', event_id: 'd1', message: 'hello!', timestamp: 't' }, + { type: 'user_message', event_id: 'u2', message: 'follow up', timestamp: 't' }, + { type: 'devin_message', event_id: 'd2', message: 'sure thing', timestamp: 't' }, + ], + }), { status: 200 }); + }, + 'POST /v1/sessions/devin-existing/message': async ({ init }) => { + messageBody = JSON.parse(init.body); + return new Response('', { status: 200 }); + }, + }); + + const result = await handleDevinChat({ + model: 'devin', + messages: [...prior, { role: 'user', content: 'follow up' }], + }); + assert.equal(result.status, 200); + assert.equal(createCalls, 0, 'must not create a new session when fingerprint hits'); + assert.equal(messageBody?.message, 'follow up'); + assert.match(result.body.choices[0].message.content, /sure thing/); + // Should not double-emit the pre-existing assistant text from the prior turn + assert.doesNotMatch(result.body.choices[0].message.content, /hello!/); + }); + + it('X-Devin-Session-Id header overrides the fingerprint cache', async () => { + // Seed a cache entry pointing at a different session id; the header + // must win. + store(fingerprint('', [{ role: 'user', content: 'first' }]), 'devin-from-cache'); + + let createCalls = 0; + let messageTarget = null; + let getCalls = 0; + installFetchStub({ + 'POST /v1/sessions': async () => { createCalls++; return new Response('{}', { status: 200 }); }, + 'POST /v1/sessions/devin-explicit/message': async ({ init }) => { + messageTarget = JSON.parse(init.body); + return new Response('', { status: 200 }); + }, + 'GET /v1/sessions/devin-explicit': async () => { + getCalls++; + // First GET is the pre-send snapshot (used to capture cursor) + if (getCalls === 1) { + return new Response(JSON.stringify({ + session_id: 'devin-explicit', + status: 'blocked', status_enum: 'blocked', + messages: [ + { type: 'user_message', event_id: 'u_prev', message: 'first', timestamp: 't' }, + { type: 'devin_message', event_id: 'd_prev', message: 'previous reply', timestamp: 't' }, + ], + }), { status: 200 }); + } + // Subsequent polls return the new turn's events after the cursor + return new Response(JSON.stringify({ + session_id: 'devin-explicit', + status: 'finished', status_enum: 'finished', + messages: [ + { type: 'user_message', event_id: 'u_prev', message: 'first', timestamp: 't' }, + { type: 'devin_message', event_id: 'd_prev', message: 'previous reply', timestamp: 't' }, + { type: 'user_message', event_id: 'u1', message: 'next', timestamp: 't' }, + { type: 'devin_message', event_id: 'd1', message: 'overridden', timestamp: 't' }, + ], + }), { status: 200 }); + }, + }); + const result = await handleDevinChat( + { model: 'devin', messages: [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'first reply' }, + { role: 'user', content: 'next' }, + ]}, + { headers: { 'x-devin-session-id': 'devin-explicit' } }, + ); + assert.equal(result.status, 200); + assert.equal(createCalls, 0); + assert.equal(messageTarget.message, 'next'); + assert.match(result.body.choices[0].message.content, /overridden/); + assert.equal(result.headers['x-devin-session-id'], 'devin-explicit'); + }); + + it('falls back to creating a new session when cached session is gone (404)', async () => { + store(fingerprint('', [{ role: 'user', content: 'old' }]), 'devin-dead'); + let createCalls = 0; + installFetchStub({ + 'GET /v1/sessions/devin-dead': async () => new Response(JSON.stringify({ detail: 'not found' }), { status: 404 }), + 'POST /v1/sessions': async () => { createCalls++; return new Response(JSON.stringify({ session_id: 'devin-fresh', url: '/' }), { status: 200 }); }, + 'GET /v1/sessions/devin-fresh': async () => new Response(JSON.stringify({ + session_id: 'devin-fresh', status: 'finished', status_enum: 'finished', + messages: [{ type: 'devin_message', event_id: 'd1', message: 'fresh', timestamp: 't' }], + }), { status: 200 }), + }); + const result = await handleDevinChat({ + model: 'devin', messages: [ + { role: 'user', content: 'old' }, + { role: 'assistant', content: 'old reply' }, + { role: 'user', content: 'new turn' }, + ], + }); + assert.equal(result.status, 200); + assert.equal(createCalls, 1); + assert.match(result.body.choices[0].message.content, /fresh/); + }); + + it('header path keeps polling when Devin still shows stale terminal status (cursor unchanged)', async () => { + // Regression: real Devin sessions can briefly continue to report + // status_enum=blocked AFTER we send a follow-up message, before the state + // machine transitions back to working. Without the cursor-progress + // requirement we would return the previous turn's assistant text. + let getCalls = 0; + installFetchStub({ + 'POST /v1/sessions/devin-pin/message': async () => new Response('', { status: 200 }), + 'GET /v1/sessions/devin-pin': async () => { + getCalls++; + if (getCalls === 1) { + // pre-send snapshot + return new Response(JSON.stringify({ + session_id: 'devin-pin', status: 'blocked', status_enum: 'blocked', + messages: [ + { type: 'devin_message', event_id: 'd_old', message: 'old answer', timestamp: 't' }, + ], + }), { status: 200 }); + } + if (getCalls < 4) { + // Devin still shows the stale terminal state immediately after send + return new Response(JSON.stringify({ + session_id: 'devin-pin', status: 'blocked', status_enum: 'blocked', + messages: [ + { type: 'devin_message', event_id: 'd_old', message: 'old answer', timestamp: 't' }, + ], + }), { status: 200 }); + } + // Eventually the new event lands + return new Response(JSON.stringify({ + session_id: 'devin-pin', status: 'finished', status_enum: 'finished', + messages: [ + { type: 'devin_message', event_id: 'd_old', message: 'old answer', timestamp: 't' }, + { type: 'user_message', event_id: 'u1', message: 'follow-up', timestamp: 't' }, + { type: 'devin_message', event_id: 'd_new', message: 'fresh answer', timestamp: 't' }, + ], + }), { status: 200 }); + }, + }); + const result = await handleDevinChat( + { model: 'devin', messages: [{ role: 'user', content: 'follow-up' }] }, + { headers: { 'x-devin-session-id': 'devin-pin' } }, + ); + assert.equal(result.status, 200); + assert.equal(result.body.choices[0].finish_reason, 'stop'); + assert.doesNotMatch(result.body.choices[0].message.content, /old answer/, 'must not surface stale assistant text from prior turn'); + assert.match(result.body.choices[0].message.content, /fresh answer/); + }); + + it('returns finish_reason=length when polling times out without terminal status', async () => { + config.devinMaxWaitMs = 20; + installFetchStub({ + 'POST /v1/sessions': async () => new Response(JSON.stringify({ session_id: 'devin-slow', url: '/' }), { status: 200 }), + 'GET /v1/sessions/devin-slow': async () => new Response(JSON.stringify({ + session_id: 'devin-slow', status: 'working', status_enum: 'working', + messages: [{ type: 'devin_message', event_id: 'd1', message: 'still thinking', timestamp: 't' }], + }), { status: 200 }), + }); + const result = await handleDevinChat({ model: 'devin', messages: [{ role: 'user', content: 'go' }] }); + assert.equal(result.status, 200); + assert.equal(result.body.choices[0].finish_reason, 'length'); + assert.match(result.body.choices[0].message.content, /still thinking/); + }); + + it('maps Devin 401 to OpenAI authentication_error', async () => { + installFetchStub({ + 'POST /v1/sessions': async () => new Response(JSON.stringify({ detail: 'invalid api key' }), { status: 401 }), + }); + const result = await handleDevinChat({ model: 'devin', messages: [{ role: 'user', content: 'hi' }] }); + assert.equal(result.status, 401); + assert.equal(result.body.error.type, 'authentication_error'); + assert.match(result.body.error.message, /invalid api key/); + }); +}); + +describe('stream mode', () => { + it('emits SSE chunks for each new Devin message and a final stop chunk', async () => { + let polls = 0; + installFetchStub({ + 'POST /v1/sessions': async () => new Response(JSON.stringify({ session_id: 'devin-s1', url: '/' }), { status: 200 }), + 'GET /v1/sessions/devin-s1': async () => { + polls++; + const finished = polls >= 3; + return new Response(JSON.stringify({ + session_id: 'devin-s1', + status: finished ? 'finished' : 'working', + status_enum: finished ? 'finished' : 'working', + messages: polls === 1 + ? [{ type: 'devin_message', event_id: 'd1', message: 'first', timestamp: 't' }] + : polls === 2 + ? [ + { type: 'devin_message', event_id: 'd1', message: 'first', timestamp: 't' }, + { type: 'devin_message', event_id: 'd2', message: 'second', timestamp: 't' }, + ] + : [ + { type: 'devin_message', event_id: 'd1', message: 'first', timestamp: 't' }, + { type: 'devin_message', event_id: 'd2', message: 'second', timestamp: 't' }, + { type: 'devin_message', event_id: 'd3', message: 'final', timestamp: 't' }, + ], + }), { status: 200 }); + }, + }); + const result = await handleDevinChat({ + model: 'devin', stream: true, + messages: [{ role: 'user', content: 'go' }], + }); + assert.equal(result.stream, true); + + // Drive the handler against a minimal mock res. + const chunks = []; + const mockRes = { + writableEnded: false, + write(chunk) { chunks.push(String(chunk)); return true; }, + end() { this.writableEnded = true; }, + on() {}, + }; + await result.handler(mockRes); + const joined = chunks.join(''); + assert.match(joined, /"role":"assistant"/, 'initial assistant role chunk should be emitted'); + assert.match(joined, /first/); + assert.match(joined, /second/); + assert.match(joined, /final/); + assert.match(joined, /"finish_reason":"stop"/); + assert.match(joined, /data: \[DONE\]/); + }); +}); diff --git a/test/devin-passthrough.test.js b/test/devin-passthrough.test.js new file mode 100644 index 00000000..8e549139 --- /dev/null +++ b/test/devin-passthrough.test.js @@ -0,0 +1,518 @@ +/** + * Devin Cloud REST passthrough — covers the /v1/devin/* mount in + * handlers/devin-passthrough.js. Strategy mirrors devin-adapter.test.js: + * stub the global `fetch` to a route table so the handler exercises its + * full pipeline (path match → auth → body shaping → response stream) + * without touching the network. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable, Writable } from 'node:stream'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { config } from '../src/config.js'; +import { + handleDevinPassthrough, + matchRoute, + ALLOWED_ROUTES, +} from '../src/handlers/devin-passthrough.js'; + +const originalFetch = globalThis.fetch; +const originalDevinKey = config.devinApiKey; +const originalDevinBase = config.devinApiBase; + +function installFetchStub(routes) { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + const u = typeof url === 'string' ? url : url.url; + const parsed = new URL(u); + const method = (init.method || 'GET').toUpperCase(); + const key = `${method} ${parsed.pathname}`; + calls.push({ url: u, method, init, headers: init.headers || {}, body: init.body }); + const handler = routes[key]; + if (!handler) { + return new Response(JSON.stringify({ detail: `No stub for ${key}` }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + return handler({ url: u, init, parsed }); + }; + return calls; +} + +function restoreFetch() { globalThis.fetch = originalFetch; } + +// Lightweight stand-ins for Node's IncomingMessage / ServerResponse so +// we can drive handleDevinPassthrough without spinning up an http +// server per test. Only the shape the handler relies on is implemented. +function mockReq({ method = 'GET', url = '/v1/devin/sessions', headers = {}, body = null }) { + const lower = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; + const stream = Readable.from(body ? [Buffer.from(body)] : []); + stream.method = method; + stream.url = url; + stream.headers = lower; + return stream; +} + +function mockRes() { + const chunks = []; + let status = 0; + let headers = {}; + const w = new Writable({ + write(chunk, enc, cb) { chunks.push(Buffer.from(chunk)); cb(); }, + }); + w.writeHead = (s, h) => { status = s; headers = { ...h }; }; + w.setHeader = (k, v) => { headers[k] = v; }; + w._status = () => status; + w._headers = () => headers; + w._body = () => Buffer.concat(chunks); + w._json = () => { + const txt = Buffer.concat(chunks).toString('utf-8'); + return txt ? JSON.parse(txt) : null; + }; + // Override end so .writableEnded flips true the way the real Response does + const origEnd = w.end.bind(w); + w.end = (...args) => { origEnd(...args); }; + return w; +} + +beforeEach(() => { + config.devinApiKey = 'apk_test_passthrough'; + config.devinApiBase = 'https://api.devin.ai'; +}); + +afterEach(() => { + restoreFetch(); + config.devinApiKey = originalDevinKey; + config.devinApiBase = originalDevinBase; +}); + +describe('matchRoute', () => { + it('matches static and parametric session routes', () => { + assert.deepEqual(matchRoute('GET', '/sessions'), { upstreamPath: '/v1/sessions', params: {} }); + assert.deepEqual(matchRoute('POST', '/sessions'), { upstreamPath: '/v1/sessions', params: {} }); + + const got = matchRoute('GET', '/sessions/devin-abc'); + assert.deepEqual(got, { upstreamPath: '/v1/sessions/devin-abc', params: { id: 'devin-abc' } }); + + const sendMsg = matchRoute('POST', '/sessions/devin-x/message'); + assert.deepEqual(sendMsg, { upstreamPath: '/v1/sessions/devin-x/message', params: { id: 'devin-x' } }); + + const del = matchRoute('DELETE', '/sessions/devin-y'); + assert.deepEqual(del, { upstreamPath: '/v1/sessions/devin-y', params: { id: 'devin-y' } }); + }); + + it('matches every method/path tuple in ALLOWED_ROUTES exactly once', () => { + for (const [method, pattern] of ALLOWED_ROUTES) { + const concrete = fillPattern(pattern); + const got = matchRoute(method, concrete); + assert.ok(got, `${method} ${concrete} should match`); + } + }); + + it('rejects unknown paths and wrong methods', () => { + assert.equal(matchRoute('PATCH', '/sessions'), null); // wrong method + assert.equal(matchRoute('GET', '/sessions/abc/extra'), null); // extra segment + assert.equal(matchRoute('GET', '/unknown'), null); + assert.equal(matchRoute('POST', '/secrets/abc'), null); // POST /secrets/:id not allowed + }); + + it('url-encodes path params so colons in devin ids pass through', () => { + const got = matchRoute('GET', '/sessions/' + encodeURIComponent('devin-abc:xyz')); + assert.ok(got); + assert.match(got.upstreamPath, /devin-abc%3Axyz/); + }); + + it('routes /v3 organization paths to /v3/organizations//... upstream', () => { + const create = matchRoute('POST', '/v3/organizations/org-abc/sessions'); + assert.deepEqual(create, { upstreamPath: '/v3/organizations/org-abc/sessions', params: { org_id: 'org-abc' } }); + + const get = matchRoute('GET', '/v3/organizations/org-abc/sessions/devin-xyz'); + assert.deepEqual(get, { + upstreamPath: '/v3/organizations/org-abc/sessions/devin-xyz', + params: { org_id: 'org-abc', devin_id: 'devin-xyz' }, + }); + + const msg = matchRoute('POST', '/v3/organizations/org-abc/sessions/devin-xyz/messages'); + assert.equal(msg.upstreamPath, '/v3/organizations/org-abc/sessions/devin-xyz/messages'); + + const arch = matchRoute('POST', '/v3/organizations/org-abc/sessions/devin-xyz/archive'); + assert.equal(arch.upstreamPath, '/v3/organizations/org-abc/sessions/devin-xyz/archive'); + + const insightsGen = matchRoute('POST', '/v3/organizations/org-abc/sessions/devin-xyz/insights/generate'); + assert.equal(insightsGen.upstreamPath, '/v3/organizations/org-abc/sessions/devin-xyz/insights/generate'); + }); + + it('prefers the static /sessions/insights row over the :devin_id capture', () => { + // Both rows have the same segment count (6) — the matcher must return + // the static one because it is listed earlier in ALLOWED_ROUTES. + const got = matchRoute('GET', '/v3/organizations/org-abc/sessions/insights'); + assert.deepEqual(got, { upstreamPath: '/v3/organizations/org-abc/sessions/insights', params: { org_id: 'org-abc' } }); + }); + + it('routes /v3 enterprise + /v2 enterprise paths verbatim', () => { + assert.deepEqual(matchRoute('GET', '/v3/enterprise/sessions'), { upstreamPath: '/v3/enterprise/sessions', params: {} }); + assert.deepEqual(matchRoute('GET', '/v3/enterprise/playbooks/pb-1'), { upstreamPath: '/v3/enterprise/playbooks/pb-1', params: { playbook_id: 'pb-1' } }); + + assert.deepEqual(matchRoute('GET', '/v2/enterprise/audit-logs'), { upstreamPath: '/v2/enterprise/audit-logs', params: {} }); + assert.deepEqual(matchRoute('GET', '/v2/enterprise/consumption/cycles'), { upstreamPath: '/v2/enterprise/consumption/cycles', params: {} }); + + // The static "members/organizations" row must win over the + // /members/:member_id capture even though both have 4 segments. + assert.deepEqual(matchRoute('GET', '/v2/enterprise/members/organizations'), { upstreamPath: '/v2/enterprise/members/organizations', params: {} }); + assert.deepEqual(matchRoute('GET', '/v2/enterprise/members/mem-1'), { upstreamPath: '/v2/enterprise/members/mem-1', params: { member_id: 'mem-1' } }); + + // Bulk revoke (no key id) vs single revoke — same method, different segment count + assert.deepEqual(matchRoute('DELETE', '/v2/enterprise/api-keys'), { upstreamPath: '/v2/enterprise/api-keys', params: {} }); + assert.deepEqual(matchRoute('DELETE', '/v2/enterprise/api-keys/key-9'), { upstreamPath: '/v2/enterprise/api-keys/key-9', params: { key_id: 'key-9' } }); + }); +}); + +/** + * Generate a concrete sample path for a route pattern by replacing every + * `:name` placeholder with `sample-`. The same substitution is + * used on the upstream template so the walker test can validate that + * the proxy forwards to the right URL regardless of how many path + * params a route declares (the v1 list only uses `:id`, but the v3 + * list uses `:org_id`, `:devin_id`, `:note_id`, `:playbook_id`, + * `:secret_id`, `:attachment_id`, `:user_id`, etc.). + */ +function fillPattern(input) { + return input.replace(/(:|\$\{)(\w+)\}?/g, (_, _prefix, name) => `sample-${name}`); +} + +describe('handleDevinPassthrough — auth + routing', () => { + it('returns 503 when DEVIN_API_KEY is missing', async () => { + config.devinApiKey = ''; + installFetchStub({}); + const req = mockReq({ method: 'GET', url: '/v1/devin/sessions' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 503); + assert.equal(res._json().error.type, 'configuration_error'); + }); + + it('returns 404 for an unknown sub-path', async () => { + installFetchStub({}); + const req = mockReq({ method: 'GET', url: '/v1/devin/unknown' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 404); + assert.equal(res._json().error.type, 'not_found'); + }); + + it('returns 404 for an empty sub-path', async () => { + installFetchStub({}); + const req = mockReq({ method: 'GET', url: '/v1/devin' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 404); + }); + + it('rejects an unsupported HTTP method with 404', async () => { + installFetchStub({}); + const req = mockReq({ method: 'PATCH', url: '/v1/devin/sessions' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 404); + }); +}); + +describe('handleDevinPassthrough — body shaping', () => { + it('forwards Bearer DEVIN_API_KEY and JSON body to the upstream URL', async () => { + const calls = installFetchStub({ + 'POST /v1/sessions': () => new Response(JSON.stringify({ session_id: 'devin-new' }), { + status: 201, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ + method: 'POST', + url: '/v1/devin/sessions', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'hi', max_acu_limit: 5 }), + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 201); + assert.deepEqual(res._json(), { session_id: 'devin-new' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://api.devin.ai/v1/sessions'); + assert.equal(calls[0].headers.Authorization, 'Bearer apk_test_passthrough'); + assert.equal(calls[0].headers['Content-Type'], 'application/json'); + assert.equal(JSON.parse(calls[0].body).prompt, 'hi'); + }); + + it('returns 400 on invalid JSON without hitting upstream', async () => { + const calls = installFetchStub({}); + const req = mockReq({ + method: 'POST', + url: '/v1/devin/sessions', + headers: { 'content-type': 'application/json' }, + body: '{not json', + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 400); + assert.equal(res._json().error.type, 'invalid_request'); + assert.equal(calls.length, 0); + }); + + it('GET requests do not send a body and preserve the query string', async () => { + const calls = installFetchStub({ + 'GET /v1/sessions': () => new Response(JSON.stringify({ sessions: [] }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ method: 'GET', url: '/v1/devin/sessions?limit=10&cursor=abc' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + assert.equal(calls[0].url, 'https://api.devin.ai/v1/sessions?limit=10&cursor=abc'); + assert.equal(calls[0].init.body, undefined); + }); + + it('multipart upload forwards the raw Content-Type and streams the body', async () => { + const calls = installFetchStub({ + 'POST /v1/attachments': async ({ init }) => { + // Consume the streamed body so the test can assert it ran. + let len = 0; + if (init.body && typeof init.body.getReader === 'function') { + const reader = init.body.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + len += value.length; + } + } + return new Response(JSON.stringify({ id: 'attach-1', bytes: len }), { + status: 201, headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'; + const body = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="t.txt"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--${boundary}--\r\n`; + const req = mockReq({ + method: 'POST', + url: '/v1/devin/attachments', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + body, + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 201); + const out = res._json(); + assert.equal(out.id, 'attach-1'); + assert.equal(out.bytes, Buffer.byteLength(body)); + assert.equal(calls[0].headers['Content-Type'], `multipart/form-data; boundary=${boundary}`); + }); + + it('forwards 302 redirects verbatim (presigned download URL)', async () => { + installFetchStub({ + 'GET /v1/attachments/foo/file': () => new Response('', { + status: 302, + headers: { 'Location': 'https://devin-attachments.example.com/presigned?sig=x' }, + }), + }); + const req = mockReq({ method: 'GET', url: '/v1/devin/attachments/foo/file' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 302); + assert.equal(res._headers().Location, 'https://devin-attachments.example.com/presigned?sig=x'); + }); +}); + +describe('handleDevinPassthrough — error pass-through', () => { + it('forwards non-2xx upstream responses with their JSON body and status', async () => { + installFetchStub({ + 'POST /v1/sessions': () => new Response(JSON.stringify({ detail: 'Bad API key' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ + method: 'POST', url: '/v1/devin/sessions', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 401); + assert.deepEqual(res._json(), { detail: 'Bad API key' }); + }); + + it('maps fetch network errors to a 502 JSON envelope', async () => { + globalThis.fetch = async () => { throw new Error('network down'); }; + const req = mockReq({ method: 'GET', url: '/v1/devin/sessions' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 502); + assert.equal(res._json().error.type, 'upstream_error'); + }); +}); + +describe('handleDevinPassthrough — every allowed route reaches the right upstream URL', () => { + it('walks ALLOWED_ROUTES and checks each one passes through', async () => { + for (const [method, pattern, template] of ALLOWED_ROUTES) { + const concretePath = fillPattern(pattern); + const upstream = fillPattern(template); + const calls = installFetchStub({ + [`${method} ${upstream}`]: () => new Response(JSON.stringify({ ok: true, route: `${method} ${upstream}` }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const needsBody = method === 'POST' || method === 'PATCH' || method === 'PUT'; + const req = mockReq({ + method, + url: `/v1/devin${concretePath}`, + headers: needsBody ? { 'content-type': 'application/json' } : {}, + body: needsBody ? '{}' : null, + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200, `${method} ${concretePath} should return 200`); + assert.equal(res._json().route, `${method} ${upstream}`); + assert.equal(calls.length, 1, `${method} ${concretePath} should issue exactly one upstream call`); + assert.match(calls[0].url, new RegExp(upstream.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + }); + + it('forwards v3 organization session creation including the org_id from the URL', async () => { + const calls = installFetchStub({ + 'POST /v3/organizations/org-real/sessions': () => + new Response(JSON.stringify({ devin_id: 'devin-fresh' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ + method: 'POST', + url: '/v1/devin/v3/organizations/org-real/sessions', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'hello', max_acu_limit: 5 }), + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + assert.deepEqual(res._json(), { devin_id: 'devin-fresh' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://api.devin.ai/v3/organizations/org-real/sessions'); + assert.equal(calls[0].headers.Authorization, 'Bearer apk_test_passthrough'); + assert.equal(JSON.parse(calls[0].body).prompt, 'hello'); + }); + + it('preserves query strings on v3 list endpoints (cursor pagination)', async () => { + const calls = installFetchStub({ + 'GET /v3/organizations/org-real/sessions': () => + new Response(JSON.stringify({ items: [], has_more: false }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ + method: 'GET', + url: '/v1/devin/v3/organizations/org-real/sessions?first=50&after=cursor-abc', + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + assert.equal(calls[0].url, 'https://api.devin.ai/v3/organizations/org-real/sessions?first=50&after=cursor-abc'); + }); + + it('forwards v2 enterprise audit-logs reads through verbatim', async () => { + const calls = installFetchStub({ + 'GET /v2/enterprise/audit-logs': () => + new Response(JSON.stringify({ logs: [] }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ + method: 'GET', + url: '/v1/devin/v2/enterprise/audit-logs?limit=100', + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + assert.equal(calls[0].url, 'https://api.devin.ai/v2/enterprise/audit-logs?limit=100'); + }); + + it('rejects sub-paths not in the allowlist even if they look v3-shaped', async () => { + const calls = installFetchStub({}); + const req = mockReq({ + method: 'POST', + url: '/v1/devin/v3/organizations/org-real/sessions/devin-x/danger', + }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 404); + assert.equal(res._json().error.type, 'not_found'); + assert.equal(calls.length, 0); + }); +}); + +/** + * Adversarial: every method/path tuple advertised in docs/devin-provider.md + * must resolve to a row in ALLOWED_ROUTES. Without this the route tables in + * docs and the matcher silently drift apart — e.g. the docs once advertised + * `PUT /v3/organizations/:org_id/sessions/:devin_id/tags` while the code + * only listed DELETE on the same path. The walker would not catch that + * because it iterates rows that ARE in the table. + */ +describe('docs/devin-provider.md ↔ ALLOWED_ROUTES consistency', () => { + // Inline parser so the test stays self-contained. The route tables in + // devin-provider.md follow a fixed shape: + // | `/v1/devin/` | `METHOD1` / `METHOD2` (annotation) | + // The proxy-path is what handleDevinPassthrough sees after stripping + // the `/v1/devin/` prefix, so it is also what matchRoute consumes. + const here = dirname(fileURLToPath(import.meta.url)); + const md = readFileSync(resolve(here, '..', 'docs', 'devin-provider.md'), 'utf-8'); + + const documented = []; + for (const line of md.split('\n')) { + if (!line.startsWith('|')) continue; + const m = line.match(/^\|\s*`(\/v1\/devin\/[^`]*)`\s*\|\s*([^|]+)\|/); + if (!m) continue; + const proxyPath = m[1].replace(/^\/v1\/devin/, '') || '/'; + // Skip the OpenAI/Anthropic translation surfaces — those are not + // proxied through /v1/devin so they shouldn't be in ALLOWED_ROUTES. + if (!proxyPath.startsWith('/')) continue; + // Skip /v1/devin/_proxy/* — those are introspection endpoints + // handled inline in handleDevinPassthrough, not entries in + // ALLOWED_ROUTES (which is only for upstream-bound passthrough). + if (proxyPath.startsWith('/_proxy/')) continue; + const methodsCell = m[2]; + const methods = methodsCell.match(/`(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)`/g); + if (!methods) continue; + for (const tok of methods) { + const method = tok.replace(/`/g, ''); + documented.push({ method, path: proxyPath }); + } + } + + it('parses at least one row out of the docs (sanity)', () => { + assert.ok(documented.length > 20, `expected >20 documented routes, got ${documented.length}`); + }); + + it('every documented (METHOD, path) tuple matches a row in ALLOWED_ROUTES', () => { + const missing = []; + for (const { method, path } of documented) { + const concrete = fillPattern(path); + const got = matchRoute(method, concrete); + if (!got) missing.push(`${method} ${path}`); + } + assert.deepEqual(missing, [], `Routes in docs but missing from ALLOWED_ROUTES:\n ${missing.join('\n ')}`); + }); + + it('every ALLOWED_ROUTES row is mentioned somewhere in docs', () => { + // Build a normalized lookup of documented (METHOD, path) pairs. + const docSet = new Set(documented.map(({ method, path }) => `${method} ${path}`)); + const undocumented = []; + for (const [method, pattern] of ALLOWED_ROUTES) { + if (!docSet.has(`${method} ${pattern}`)) { + undocumented.push(`${method} ${pattern}`); + } + } + assert.deepEqual(undocumented, [], `Routes in ALLOWED_ROUTES but absent from docs:\n ${undocumented.join('\n ')}`); + }); +}); diff --git a/test/devin-v3.test.js b/test/devin-v3.test.js new file mode 100644 index 00000000..c7679aca --- /dev/null +++ b/test/devin-v3.test.js @@ -0,0 +1,395 @@ +/** + * Devin Cloud reverse proxy — v3 API surface coverage. + * + * The chat adapter and REST passthrough are exercised against both API + * surfaces in devin-adapter.test.js / devin-passthrough.test.js, but + * those default to v1. This file pins down the v3-only behaviors that + * the proxy needs to keep working as service-user (`cog_*`) tokens + * become the norm: + * + * 1. devin-client.js routes session ops to /v3/organizations//... + * when DEVIN_API_VERSION=v3 (and refuses to issue requests when + * DEVIN_ORG_ID isn't set). + * 2. Auto-detect: DEVIN_API_VERSION=auto attempts v1 first, and on a + * 401/403 falls back to v3 — only when DEVIN_ORG_ID is configured. + * Without org_id, the original 401 must propagate (better signal + * than a generic "missing org_id" error). + * 3. normalizeV3Session converts the v3 messages payload (separate + * endpoint, `source` field) into the v1-shaped `messages[]` array + * with `type` so downstream extractors keep working. + * 4. /v1/devin/_proxy/info reports configuration without leaking the + * key; /v1/devin/_proxy/routes returns the allowlist. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable, Writable } from 'node:stream'; + +import { config } from '../src/config.js'; +import { + createSession, + getSession, + sendMessage, + normalizeV3Session, + DevinApiError, + _clearAutoVersionCache, + _internals as clientInternals, +} from '../src/devin-client.js'; +import { handleDevinPassthrough } from '../src/handlers/devin-passthrough.js'; + +const ORIG = { + fetch: globalThis.fetch, + apiKey: config.devinApiKey, + apiBase: config.devinApiBase, + apiVersion: config.devinApiVersion, + orgId: config.devinOrgId, +}; + +function installFetchStub(routes) { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + const u = typeof url === 'string' ? url : url.url; + const parsed = new URL(u); + const method = (init.method || 'GET').toUpperCase(); + const key = `${method} ${parsed.pathname}`; + calls.push({ url: u, method, init, headers: init.headers || {}, body: init.body }); + const handler = routes[key]; + if (!handler) { + return new Response(JSON.stringify({ detail: `No stub for ${key}` }), { + status: 404, headers: { 'Content-Type': 'application/json' }, + }); + } + return handler({ url: u, init, parsed }); + }; + return calls; +} + +function mockReq({ method = 'GET', url = '/v1/devin/_proxy/info', headers = {}, body = null }) { + const lower = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; + const stream = Readable.from(body ? [Buffer.from(body)] : []); + stream.method = method; + stream.url = url; + stream.headers = lower; + return stream; +} + +function mockRes() { + const chunks = []; + let status = 0; + let headers = {}; + const w = new Writable({ + write(chunk, enc, cb) { chunks.push(Buffer.from(chunk)); cb(); }, + }); + w.writeHead = (s, h) => { status = s; headers = { ...h }; }; + w.setHeader = (k, v) => { headers[k] = v; }; + w._status = () => status; + w._headers = () => headers; + w._body = () => Buffer.concat(chunks); + w._json = () => { + const txt = Buffer.concat(chunks).toString('utf-8'); + return txt ? JSON.parse(txt) : null; + }; + return w; +} + +beforeEach(() => { + config.devinApiKey = 'cog_test_v3_key'; + config.devinApiBase = 'https://api.devin.ai'; + config.devinApiVersion = 'auto'; + config.devinOrgId = ''; + _clearAutoVersionCache(); +}); + +afterEach(() => { + globalThis.fetch = ORIG.fetch; + config.devinApiKey = ORIG.apiKey; + config.devinApiBase = ORIG.apiBase; + config.devinApiVersion = ORIG.apiVersion; + config.devinOrgId = ORIG.orgId; +}); + +describe('devin-client v3 routing', () => { + it('createSession with version=v3 hits /v3/organizations//sessions', async () => { + config.devinApiVersion = 'v3'; + config.devinOrgId = 'org-abc'; + const calls = installFetchStub({ + 'POST /v3/organizations/org-abc/sessions': () => + new Response(JSON.stringify({ session_id: 'devin-xyz', url: 'https://app.devin.ai/sessions/xyz' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const session = await createSession({ prompt: 'hello', max_acu_limit: 5 }); + assert.equal(session.session_id, 'devin-xyz'); + assert.equal(session._api_version, 'v3'); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'POST'); + assert.match(calls[0].url, /\/v3\/organizations\/org-abc\/sessions$/); + const sent = JSON.parse(calls[0].body); + assert.equal(sent.prompt, 'hello'); + assert.equal(sent.max_acu_limit, 5); + }); + + it('refuses v3 calls when DEVIN_ORG_ID is missing', async () => { + config.devinApiVersion = 'v3'; + config.devinOrgId = ''; + installFetchStub({}); // shouldn't be called + await assert.rejects( + () => createSession({ prompt: 'hello' }), + (e) => e instanceof DevinApiError && /DEVIN_ORG_ID/.test(e.message), + ); + }); + + it('getSession on v3 merges /sessions and /messages into a v1-shaped payload', async () => { + config.devinApiVersion = 'v3'; + config.devinOrgId = 'org-abc'; + installFetchStub({ + 'GET /v3/organizations/org-abc/sessions/devin-1': () => new Response(JSON.stringify({ + session_id: 'devin-1', + status: 'running', + status_detail: 'working', + pull_requests: [{ url: 'https://github.com/x/y/pull/1' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + 'GET /v3/organizations/org-abc/sessions/devin-1/messages': () => new Response(JSON.stringify({ + items: [ + { event_id: 'e1', source: 'user', message: 'hi', created_at: 1 }, + { event_id: 'e2', source: 'devin', message: 'hello back', created_at: 2 }, + ], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + const session = await getSession('devin-1'); + assert.equal(session.session_id, 'devin-1'); + // status_enum is synthesised from status_detail so the chat handler's + // TERMINAL_STATUSES check works on v3 payloads. + assert.equal(session.status_enum, 'working'); + // pull_request is flattened from pull_requests[0] for v1 compatibility. + assert.equal(session.pull_request.url, 'https://github.com/x/y/pull/1'); + assert.equal(session.messages.length, 2); + assert.equal(session.messages[0].type, 'user_message'); + assert.equal(session.messages[1].type, 'devin_message'); + }); + + it('sendMessage on v3 posts to /messages (note: v3 plural, v1 singular)', async () => { + config.devinApiVersion = 'v3'; + config.devinOrgId = 'org-abc'; + const calls = installFetchStub({ + 'POST /v3/organizations/org-abc/sessions/devin-1/messages': () => + new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + await sendMessage('devin-1', 'follow-up'); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /\/v3\/organizations\/org-abc\/sessions\/devin-1\/messages$/); + assert.equal(JSON.parse(calls[0].body).message, 'follow-up'); + }); + + it('forwards Authorization: Bearer with the configured key on every v3 call', async () => { + config.devinApiVersion = 'v3'; + config.devinOrgId = 'org-abc'; + config.devinApiKey = 'cog_secret123'; + const calls = installFetchStub({ + 'POST /v3/organizations/org-abc/sessions': () => + new Response(JSON.stringify({ session_id: 'd' }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + await createSession({ prompt: 'x' }); + const got = calls[0].headers; + const headers = got instanceof Headers ? Object.fromEntries(got.entries()) : got; + assert.equal(headers.Authorization || headers.authorization, 'Bearer cog_secret123'); + }); +}); + +describe('devin-client auto-detect', () => { + it('auto falls back to v3 when v1 returns 401 (with no org_id hint, v1 is tried first)', async () => { + config.devinApiVersion = 'auto'; + // Don't set org_id up front — we want the loader to default to v1 first + // so we can exercise the 401-fallback path. The 401 handler in + // devin-client uses `config.devinOrgId` to decide whether to retry on + // v3, so we set it *after* installing the fetch stub. + config.devinOrgId = ''; + const calls = installFetchStub({ + 'POST /v1/sessions': () => { + // Set org_id just before the v3 retry would happen — emulates the + // operator configuring both DEVIN_API_KEY and DEVIN_ORG_ID at + // startup, then triggering the first call. + config.devinOrgId = 'org-abc'; + return new Response(JSON.stringify({ detail: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + }, + 'POST /v3/organizations/org-abc/sessions': () => new Response(JSON.stringify({ session_id: 'devin-1' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const session = await createSession({ prompt: 'hello' }); + assert.equal(session.session_id, 'devin-1'); + assert.equal(session._api_version, 'v3'); + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/v1\/sessions$/); + assert.match(calls[1].url, /\/v3\/organizations\/org-abc\/sessions$/); + // After fallback, the choice is cached so a second call goes straight to v3. + const session2 = await createSession({ prompt: 'second' }); + assert.equal(session2.session_id, 'devin-1'); + assert.equal(calls.length, 3); + assert.match(calls[2].url, /\/v3\/organizations\/org-abc\/sessions$/); + }); + + it('auto re-raises the original 401 when DEVIN_ORG_ID is not set', async () => { + config.devinApiVersion = 'auto'; + config.devinOrgId = ''; + installFetchStub({ + 'POST /v1/sessions': () => new Response(JSON.stringify({ detail: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + }); + await assert.rejects( + () => createSession({ prompt: 'hi' }), + (e) => e instanceof DevinApiError && e.status === 401, + ); + }); + + it('auto starts on v3 when DEVIN_ORG_ID hints v3 was intended', async () => { + config.devinApiVersion = 'auto'; + config.devinOrgId = 'org-abc'; + const calls = installFetchStub({ + 'POST /v3/organizations/org-abc/sessions': () => new Response(JSON.stringify({ session_id: 'devin-1' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + await createSession({ prompt: 'x' }); + // No v1 round-trip when org_id is configured up front. + assert.equal(calls.filter(c => c.url.includes('/v1/sessions')).length, 0); + assert.equal(calls.filter(c => c.url.includes('/v3/organizations/org-abc/sessions')).length, 1); + }); +}); + +describe('normalizeV3Session', () => { + it('synthesises status_enum from status_detail', () => { + const norm = normalizeV3Session({ status: 'running', status_detail: 'blocked' }, { items: [] }); + assert.equal(norm.status_enum, 'blocked'); + }); + + it('falls back to status when status_detail is missing', () => { + const norm = normalizeV3Session({ status: 'finished' }, { items: [] }); + assert.equal(norm.status_enum, 'finished'); + }); + + it('maps source → type for all known sources', () => { + const norm = normalizeV3Session({}, { + items: [ + { event_id: 'a', source: 'user', message: 'q' }, + { event_id: 'b', source: 'devin', message: 'a' }, + { event_id: 'c', source: 'agent', message: 'a2' }, + { event_id: 'd', source: 'system', message: 'note' }, + ], + }); + assert.equal(norm.messages[0].type, 'user_message'); + assert.equal(norm.messages[1].type, 'devin_message'); + assert.equal(norm.messages[2].type, 'devin_message'); + assert.equal(norm.messages[3].type, 'system'); + }); + + it('preserves event_id, message, and any extra fields', () => { + const norm = normalizeV3Session({}, { + items: [{ event_id: 'e1', source: 'user', message: 'hi', created_at: 1234, extra: 'x' }], + }); + assert.equal(norm.messages[0].event_id, 'e1'); + assert.equal(norm.messages[0].message, 'hi'); + assert.equal(norm.messages[0].created_at, 1234); + assert.equal(norm.messages[0].extra, 'x'); + }); + + it('handles array-shaped messages payload (defensive)', () => { + const norm = normalizeV3Session({}, [{ event_id: 'x', source: 'devin', message: 'hi' }]); + assert.equal(norm.messages.length, 1); + assert.equal(norm.messages[0].type, 'devin_message'); + }); + + it('coerces missing items into an empty array', () => { + const norm = normalizeV3Session({ status: 'running' }, null); + assert.deepEqual(norm.messages, []); + }); +}); + +describe('/v1/devin/_proxy/info', () => { + it('reports configured + version + masked key + cached effective version', async () => { + config.devinApiKey = 'cog_abcd1234efgh5678'; + config.devinApiVersion = 'auto'; + config.devinOrgId = 'org-abc'; + // Seed the auto-detect cache so the response surfaces it. + clientInternals._autoVersionCache.set('cog_abcd1234efgh5678', 'v3'); + + const req = mockReq({ method: 'GET', url: '/v1/devin/_proxy/info' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + const info = res._json(); + assert.equal(info.configured, true); + assert.equal(info.api_version_setting, 'auto'); + assert.equal(info.org_id, 'org-abc'); + assert.equal(info.api_key_prefix, 'cog_'); + assert.equal(info.cached_effective_version, 'v3'); + // The actual key value MUST NOT be in the payload. + assert.ok(!JSON.stringify(info).includes('abcd1234efgh5678')); + assert.match(info.api_key_mask, /cog_…5678/); + }); + + it('returns 503 when DEVIN_API_KEY is unset', async () => { + config.devinApiKey = ''; + const req = mockReq({ method: 'GET', url: '/v1/devin/_proxy/info' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 503); + assert.equal(res._json().error.type, 'configuration_error'); + }); + + it('with ?probe=1, runs a real probe against v1 and v3', async () => { + config.devinApiKey = 'cog_key'; + config.devinOrgId = 'org-abc'; + const calls = installFetchStub({ + 'GET /v1/sessions': () => new Response(JSON.stringify({ detail: 'Unauthorized' }), { status: 401 }), + 'GET /v3/organizations/org-abc/sessions': () => new Response(JSON.stringify({ items: [] }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + }); + const req = mockReq({ method: 'GET', url: '/v1/devin/_proxy/info?probe=1' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + const info = res._json(); + assert.ok(info.probe); + assert.equal(info.probe.v1.status, 401); + assert.equal(info.probe.v3.status, 200); + assert.equal(info.probe.effective, 'v3'); + assert.equal(calls.length, 2); + }); + + it('probe reports "DEVIN_ORG_ID not configured" when org_id is missing', async () => { + config.devinApiKey = 'cog_key'; + config.devinOrgId = ''; + installFetchStub({ + 'GET /v1/sessions': () => new Response(JSON.stringify({ detail: 'Unauthorized' }), { status: 401 }), + }); + const req = mockReq({ method: 'GET', url: '/v1/devin/_proxy/info?probe=1' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + const info = res._json(); + assert.equal(info.probe.v3.ok, false); + assert.match(info.probe.v3.error, /DEVIN_ORG_ID/); + }); +}); + +describe('/v1/devin/_proxy/routes', () => { + it('returns the static allowlist as JSON', async () => { + config.devinApiKey = 'cog_key'; + const req = mockReq({ method: 'GET', url: '/v1/devin/_proxy/routes' }); + const res = mockRes(); + await handleDevinPassthrough(req, res); + assert.equal(res._status(), 200); + const body = res._json(); + assert.ok(Array.isArray(body.routes)); + assert.ok(body.count > 20); + const sessionsRoute = body.routes.find(r => r.method === 'POST' && r.pattern === '/sessions'); + assert.ok(sessionsRoute); + assert.equal(sessionsRoute.upstream, '/v1/sessions'); + // No fetch should fire — this is a pure-config endpoint. + }); +});