# DEVIN_CONNECT Production Cutover Runbook **Created:** 2026-06-30 (eve of Cascade retirement, 2026-07-01) **Audience:** operator deploying WindsurfAPI to homecloud after Cascade is gone. **Status:** **cutover is done.** `DEVIN_CONNECT=1` has been the deployed backend since 2026-07; every release since has been developed and measured against it, and the §8 paid calibration below was captured on a real account (2026-07-27). Read this as a runbook for a NEW deployment or a rollback, not as a pending migration. > The line here used to read *"deploy-ready … NOT yet pushed/deployed — production still > runs old code that 503s on Devin routes"*, with a test count from 2026-06-30. That was > true the day it was written and became false within weeks, while > [README.md](README.md) was telling readers this same backend was the production default. > Two docs, opposite claims. A "Status:" field pinned to a moment needs either a date > qualifier or no field at all — this one now says what is *durably* true and leaves the > per-release numbers to the handoffs, which are dated by construction. --- ## 0. The one thing that matters most There are **two** Devin switches and they are not interchangeable: | Switch | Routes to | Needs a binary? | Use on homecloud? | |---|---|---|---| | **`DEVIN_CONNECT=1`** | Devin **cloud** GetChatMessage over pure HTTP, riding the account pool | **No** | **YES — this one** | | `DEVIN_ONLY=1` | the **local `devin` CLI** subprocess | Yes (`devin` binary) | No — homecloud has no binary → 503 per request | Homecloud has neither the `language_server` binary nor the `devin` CLI, so the cutover switch is **`DEVIN_CONNECT=1`**. Setting `DEVIN_ONLY=1` here would make every request 503. --- ## 1. Minimal cutover config On homecloud, in the service `.env` (workdir `/home/dwgx_user/WindsurfAPI`, loopback `127.0.0.1:3003` per memory `homecloud-deploy`): ```sh DEVIN_CONNECT=1 API_KEY=sk-REDACTED # existing downstream proxy key — unchanged # accounts.json already holds the free session token(s) — nothing else required ``` That is the whole minimum. The pool supplies tokens. Which connect selectors are FREE is **derived from the live catalog, not from a fixed name**: 1. `modelCostTier` + `promoStatus` on the account's `GetCascadeModelConfigs` rows (per account, promo-aware). Measured on a Pro account 2026-10-22: exactly `swe-2-medium` / `swe-2-high` / `swe-2-max`, `MODEL_COST_TIER_FREE`, promo active until **2026-11-01T06:59:59Z**. 2. A `rate === 0` entry in the account's paired credit rate table (rolling promotions that are not encoded as a cost tier). 3. `FREE_REACHABLE_SELECTORS` (`swe-1-6-slow`) — a documented **fallback only** for a deployment with no live cost-tier data and no rate table (cold start). It is also unpaid-tier ENTITLEMENT, so a free account is still admitted to it when the live catalog has not been fetched. Do not treat it as "the free list": on the measured Pro deployment it is absent from the 240-selector live catalog and the upstream answers it with `failed_precondition` / "Your daily usage quota has been exhausted". `GET /dashboard/api/models` exposes the derivation per row: `costTier`, `promoEndsAt`, `freeNow` (tri-state), `freeReason` (`cost_tier_free` | `rate_zero` | `fallback_whitelist`) and `quotaState` (`free` | `blocked_daily` | `blocked_weekly` | `not_in_catalog` | `unknown`). A selector absent from a non-empty live catalog is reported as `not_in_catalog` rather than reachable+free. A model name the catalog does not know is **rejected with `400 model_not_found`**, not degraded. This sentence used to say the opposite ("any other model name degrades to that free selector"), which stopped being true when the strict-model guard shipped with default `WINDSURFAPI_STRICT_MODEL=1`: silently substituting the free selector changes both the output and the billing semantics of an explicitly requested paid model, so it fails loudly instead. Setting `WINDSURFAPI_STRICT_MODEL=0` restores the legacy silent degrade — see the env reference for why that is a footgun rather than a workaround. That opt-out degrades **only to a selector that is callable**: the live free selectors above, restricted to what the account's catalog actually exposes. It used to target the frozen `swe-1-6-slow` unconditionally, which on a live-catalog deployment is absent and answers with "Your daily usage quota has been exhausted" — so a typo came back as a quota error that looked like an account fault. When no live free selector qualifies, the request is answered `400 model_not_found` even with the opt-out set; the static `swe-1-6-slow` floor now applies only while no live catalog has arrived (cold start). ## 2. Recommended hardening adds (optional, all default-off) ```sh DEVIN_CONNECT_LIVENESS_PROBE=1 # zero-billable dead-token detection sweep DEVIN_CONNECT_AUTO_RELOGIN=1 # self-heal a dead token via Auth1 re-login DEVIN_CONNECT_CRED_KEY=<32+ char secret> # REQUIRED for auto-relogin to do anything ``` Auto-relogin only works for accounts that have a **stored password**. An account added by raw token (the current pooled free account, id `70da7667`) has none, so it can fail over to other pool members but cannot self-heal its own token. To enable self-heal, re-add the account through the email/password login path with `DEVIN_CONNECT_CRED_KEY` set — the password is then auto-stored encrypted (`src/auth.js:1981`). See §5. ## 3. Deploy steps (operator runs on homecloud) ```sh cd /home/dwgx_user/WindsurfAPI git pull # pulls the DEVIN_CONNECT commits # edit .env: add DEVIN_CONNECT=1 (+ optional hardening from §2) sudo systemctl restart windsurfapi sudo systemctl status windsurfapi --no-pager # confirm active (running) ``` No `npm install` needed unless `package.json` changed (it didn't for this work). ## 4. Post-deploy verification ```sh # zero-billable preflight first (no model calls): API_KEY=sk-REDACTED BASE_URL=http://127.0.0.1:3003 \ CONNECT_SMOKE_REAL_CALLS=0 npm run smoke:devin-connect # then one real free-model call to confirm the chat path yields tokens: API_KEY=sk-REDACTED BASE_URL=http://127.0.0.1:3003 \ npm run smoke:devin-connect ``` A green run proves: pool token in use, GetChatMessage reachable, free selector resolves, and (if hardening enabled) credential store + recovery-config sane. Quick manual sanity check: ```sh curl -s http://127.0.0.1:3003/v1/chat/completions \ -H "Authorization: Bearer sk-REDACTED" -H "Content-Type: application/json" \ -d '{"model":"swe-1-6-slow","messages":[{"role":"user","content":"ping"}]}' \ | head -c 400 ``` ## 5. Enabling true self-heal for the free account (optional) The pooled free account is token-only today. To give it self-heal: 1. Set `DEVIN_CONNECT_CRED_KEY` in `.env` and restart. 2. Remove the token-only account, re-add via the dashboard's email/password login (or the login API) — this stores the password encrypted. 3. Verify recovery: `npm run smoke:devin-connect` Stage 0c reports the credential store enabled and recovery-config sane. ## 6. Rollback ```sh # fastest: drop the switch and restart (reverts to prior backend selection) # edit .env: comment out DEVIN_CONNECT=1 sudo systemctl restart windsurfapi # or revert the code: git log --oneline -5 && git checkout && sudo systemctl restart windsurfapi ``` `DEVIN_CONNECT` is a pure env flag with no migration — toggling it off is an instant, total rollback. ## 6.5 Capacity throttling (`high demand`) — what to expect (P0 #56/#57) The free `swe-1-6-slow` upstream intermittently rejects a completion with `We're currently facing high demand for this model. Please try again later.`, delivered inside an HTTP **401/403** auth-shell. This is a TRANSIENT capacity condition, NOT a dead token. The proxy classifies it as `CAPACITY` and: - **retries once in place** on the same token (stream + non-stream paths), - **never triggers re-login** (so a busy blip can't burn the account), - **never escalates to `MODEL_BLOCKED`** (so the free model is never cooled down permanently over a hiccup), - on persistent busy, surfaces a clean **HTTP 503 `capacity_error`** and applies a short (60s) **model-scoped** soft cooldown so the pool prefers another account for that model — the account stays fully healthy for every other model. Watch `capacity_throttled` on `/connect-metrics`. A steady low count is normal free-tier behavior; a sustained spike means the upstream is genuinely saturated (add pool accounts or back off), not that anything is broken. **Do not** re-map `CAPACITY` to `UNAUTHORIZED` — that reintroduces the re-login storm this guards. ## 7. What's NOT covered (known limits) - **Paid models** (claude-*/gpt-*/gemini-*) need a paid entitlement on a pooled account — unverified, blocked on a paid token. A free deployment's serveable set is the live catalog's cost-tier-FREE rows, with `swe-1-6-slow` as the cold-start fallback (see §1). See §8 for the one-command verification. - **Vision/images**: native `ChatMessagePrompt.images #10` is verified by the extracted Devin schema and a successful SWE-1.7 image request. It is enabled by default; set `DEVIN_CONNECT_IMAGE_TAG=0` only for an emergency rollback. Images only reach the model on **user** turns: measured 2026-10-21, the same image on an assistant turn is accepted on the wire but ignored upstream (the model reports no image), so an image-only assistant turn is dropped before encoding. That drop is logged and counted (`assistant_images_dropped` on `/dashboard/api/connect-metrics`); move the image to a user turn. - **Router models** (`adaptive`, `arena-*`): resolution via AssignModel is built but gated OFF (`DEVIN_CONNECT_ASSIGN_MODEL` unset) because the wire tags are inferred, not yet calibrated on a paid round-trip. §8 has the enable path. - **Sequential tool calls** (`parallel_tool_calls:false`, or Anthropic's `tool_choice.disable_parallel_tool_use`): the request is always SERVED — the field is a preference and never a 400 (Codex 0.164 sends it on every request; the old refusal made the CLI unusable) — but the hint is only forwarded upstream when `DEVIN_CONNECT_TOOL_CHOICE=1` (unconfirmed `#11` tag). Otherwise the model may return several calls in one turn; the drop is logged once per process (`event=parallel_tool_calls_hint_ignored`) and counted per request as `parallel_tool_calls_hint_ignored` on `/dashboard/api/connect-metrics`. - **Billing passthrough**: cache token tags `5`/`4` ship ON by default. `committed_acu_cost` is descriptor-verified as top-level **double field 22** (`leookun/devin-2api` extract) but stays **opt-in** until a paid frame reconciles against official cycle ACU. §8.4. - Remaining P1/P2 hardening (hung-stream absolute deadline, quota-vs-tier classification, streaming transient-5xx replay, observability counters) are filed as backlog — not blockers for a free-tier cutover. ## 8. Paid-token activation — one command per capability Everything below is BUILT, TESTED (offline), and OFF by default. The moment a PAID Devin session token exists, each capability is one command away. All are billable; none run without an explicit flag. Token can come from a pooled account's `apiKey` or be passed directly. ### 8.0 START HERE — one command discovers every unknown tag at once Before reaching for the per-capability tools below (§8.2/8.4/8.6/§8.5's manual flag), run the unified calibrator. It fires ONE `DEBUG_META` completion, then aggregates every top-level + metadata tag the stream emitted, diffs them against the verified free baseline, classifies each NEW tag by wire shape into the field it most likely is, writes candidate `DEVIN_CONNECT_*` env lines to disk, and prints a per-target status table (calibrated / candidate-found / pending, and which task each unblocks: #46 billing+cache, #47 actual_model_uid, #49 tool_calls). ```sh CALIBRATE_REAL=1 CONNECT_SMOKE_TOKEN= npm run calibrate:devin # bare run (no flag) = offline self-test (simulated paid/tool/cached/router # frames) — proves collect→classify→env-gen→status works with NO token/billing. # output env file: .devin-connect-calibrated.env (gitignored) — review before pinning. ``` This replaces the hand-wiring §8.5 used to require. A free token surfaces zero candidates (expected); a paid / tool-using / router / cached turn surfaces the tags the sections below tell you how to pin. The image tag (#29) still needs the dedicated vision sweep in §8.2 (it's a REQUEST-side tag, not in the response). The calibrator also **recursively decodes the inner fields of any non-printable top-level sub-message** — most importantly the recurring `#28` trailer (a ~186b block present at the END of every completion, the prime usage/billing/stop-metadata candidate). Previously the dump could only mark it `` (presence), so its guts needed a hand-written probe; now one `calibrate:devin` run prints `sub-message #28 inner varints: {…}` for operator inspection. These sub-fields are **surfaced, not auto-wired**: the shipped billing decoder reads the `#7` meta block, so if `#28` turns out to carry credit_cost / cache tokens you map them deliberately rather than the harness guessing. (PAID-1 2026-07-01 already confirmed the sibling `#47` actual_model tag = 21 = "anthropic"; `#28` inner decode was the next step.) > Onboarding a new account is also one command: > `DEVIN_CONNECT_CRED_KEY= LOGIN_REAL=1 LOGIN_EMAIL= LOGIN_PASSWORD=

npm run login:devin` > — logs in, encrypts+stores the credential (arms auto-relogin), adds to the pool, > and verifies tier. Bare run = offline self-test. ### 8.0.1 PAID CALIBRATION RESULTS — captured 2026-07-27 (teams account) The §8.0/§8.1 tooling below sat "ready, blocked on a paid token" for months. A paid `teams` account finally ran it. Recording the outcomes so nobody re-derives them: **§8.1 paid reachability — ANSWERED.** `tier=teams paid=true`, **13/13 selectors reachable** (claude-opus-4-6 ×4 variants, claude-sonnet-4-6 ×4, gpt-5.2 ×5). The long-open question "does a paid token actually reach claude/gpt, or do they tier-wall like free?" is settled: they reach. **Free-model wall persists on a paid account.** `swe-1-6-slow` still returns `UPSTREAM_INTERNAL` on this teams token while every paid selector works. So the §8.8 free-tier note is NOT purely a free-account condition — do not read a `swe-1-6-slow` failure as "the account is dead". It also means the calibrator's default model is the one model a paid account cannot probe (see §8.0.2). **Cache metering — the tag calibration is CORRECT, and the GPT/Claude split is real.** Measured by replaying one identical ~2.3k-token prefix three times per family through `/v1/chat/completions` (stable `user` field to pin the callerKey): | | 1st call | 2nd call | 3rd call | |---|---|---|---| | **claude-sonnet-4.6** | `cache_creation=2268`, cached=none | `cached_tokens=2262`, creation=6 | `cached_tokens=2262`, creation=6 | | **gpt-5.2** | no cache fields at all | `cached_tokens=1280`, creation=**none** | `cached_tokens=1280`, creation=**none** | Conclusions: - Claude family: write-then-read behaves exactly as `DEFAULT_BILLING_TAGS` (`cache_read_tokens=5,cache_write_tokens=4`) assumes. Calibration confirmed. - GPT family: `cached_tokens` (read) IS reported, `cache_creation` (tag 4) is **never** reported. This confirms the `devin-connect.js` note that GPT selectors carry no tag 4 — their full input rides tag 2. **The cost table in PR #230, which claimed a tag-4 cache write on `gpt-5-6-sol-max`, does not reproduce here.** The PR's mechanism conclusion (rotating accounts re-pays for context) still holds — it is demonstrated by the Claude row above — but the GPT numbers in that table should not be treated as calibration data. - **Historic note, superseded by O2 (2026-10-25):** at calibration time `prompt_tokens` excluded both cached and created tokens (Claude reported `prompt=3` while 2268 rode the cache fields), matching #118. That made a cache-WRITE turn report the tiny fresh tail as the prompt; it now reports `fresh + cache_read + cache_write`. The raw counters in the table above are unchanged — only the wire mapping changed (see §8.6). **`actual_model_uid` (#47) — independently re-confirmed.** The dump shows `sub #7.9 string="claude-sonnet-4-6"`, i.e. the concrete model rides the #7 metadata sub-message at inner tag 9 — matching the 2026-07-05 opus-4-8 frame verification already noted in `devin-connect.js`. So `DEVIN_CONNECT_ACTUAL_MODEL_TAG=9` is confirmed on a second model family. (Wire position confirmed; the decode wiring was not separately exercised.) **Newly surfaced paid-only fields** (dumped, NOT wired — pin deliberately): | Position | Value seen | Reading | |---|---|---| | `#7.7` | `"msg_011CdRd3offZL5QAzA7LaNTb"` | upstream (Anthropic) message id | | `#7.8` | nested, `Date: Mon, 27 Jul 2026 …` | upstream response headers | | `#7.9` | `"claude-sonnet-4-6"` | actual_model_uid (see above) | | `#28` | `"Token Usage"` block, incl. `cached_input_tokens` / `"Cached input tokens"` | human-readable usage/labels block — the `#28` trailer §8.0 flagged | | `#2.{1,2}` | `1785112410`, `85016380` | epoch-second timestamps, NOT billing | ### 8.0.2 Calibrator gotcha: pass a SELECTOR, not an alias `streamChat` takes `model` RAW — alias resolution lives in the chat handler, not the transport — so `CALIBRATE_MODEL=claude-sonnet-4.6` (dotted alias) produced `UPSTREAM_INTERNAL`, **indistinguishable from a dead token or a walled account**, which is precisely the state a paid calibration run is trying to diagnose. Two paid runs were burned on this. `scripts/devin-connect-calibrate.mjs` now resolves the alias itself and logs `resolved alias X -> selector Y`, so either form works. **Alias vs selector on the client surface (O1, measured 2026-10-25).** Two spellings that look like the same model select DIFFERENT upstream variants: | Client asks for | Upstream selector | Notes | |---|---|---| | `claude-sonnet-4.6` (dotted) | `claude-sonnet-4-6-thinking` | curated alias default; thinking tokens, credit 6 | | `claude-sonnet-4-6` (dashed) | `claude-sonnet-4-6` | base catalog selector; no reasoning_content, credit 4 | Both resolve `mapped:true`; the alias is deliberate (only the dotted family carries the `-thinking` default) and must not be "fixed". A client that wants the base model has to use the dashed catalog spelling — omp 18.12.6 asks for the dotted name, so every omp turn on the windsurf group runs the thinking variant. ### 8.1 Verify paid selectors are actually reachable (#15/#28) The single command that resolves the long-blocked "does a paid token reach claude/gpt/gemini, or do they tier-wall like free?" question. Walks every paid selector, fires one 1-token probe each, prints a reachable/tier-wall/dead-token matrix. Probes the free model first as a liveness baseline so it can tell a tier-wall from a dead token. ```sh PAID_VERIFY_REAL=1 CONNECT_SMOKE_PAID_TOKEN= npm run verify:devin-paid # narrow: PAID_VERIFY_ONLY=claude-opus-4.8,gpt-5.5 (prepend to the line) # bare run (no flag) = offline self-test, no token/network/billing. ``` Exit 0 = all good (or free tier, where tier-walls are expected). Exit 1 = a PAID token still has tier-walls (entitlement not really active). ### 8.2 Re-calibrate the vision image tag after an upstream wire change The production default is the verified tag `10`; ordinary SWE-1.7 input keeps the image on its source=USER message and does not synthesize tool turns. This probe remains only as an emergency diagnostic if a future Devin release changes the protobuf layout. On a hit it writes `devin-connect-image-tag.json` (gitignored) and prints the exact env override. ```sh IMAGE_CALIBRATE_REAL=1 CONNECT_SMOKE_TOKEN= \ IMAGE_CALIBRATE_MODEL=claude-opus-4.8 npm run calibrate:devin-image # only after a confirmed upstream migration, put the printed override in .env. ``` Include the current known-good tag in the sweep, for example `IMAGE_CALIBRATE_TAGS=10,4,5,6,7`; do not change production from `10` on a single ambiguous response. ### 8.3 Enable router models — adaptive / arena-* (AssignModel) `adaptive`/`arena-*` are routers the server resolves per request via AssignModel; GetChatMessage rejects the bare router uid. Turn the hop on: ```sh # .env: DEVIN_CONNECT_ASSIGN_MODEL=1 # If a real AssignModel round-trip shows different tags than our defaults # (req_model_uid=2, req_cascade_id=3, resp_assignment=1, asg_model_uid=2, # asg_jwt=1, asg_harness=3), pin them: DEVIN_CONNECT_ASSIGN_TAGS="req_model_uid=2,req_cascade_id=3,resp_assignment=1,asg_model_uid=2,asg_jwt=1,asg_harness=3" # Extra router names/prefixes: DEVIN_CONNECT_ROUTER_MODELS="auto,smart-*" ``` The defaults above are the post-`86d5543` set (2026-10-15), read from `ASSIGN_TAGS_DEFAULT` (`src/devin-connect-catalog.js:521-527`): the request now carries `cascade_id` (#3, the schema marks it required) and `ModelAssignment`'s `model_uid` / assignment-blob numbers were corrected (they had been swapped). An `.env` that pinned the old set — `req_model_uid=2,resp_assignment=1,asg_model_uid=1,asg_jwt=2,asg_harness=3` — must be re-pinned or dropped; it would re-point `asg_model_uid` at the JWT field and omit `cascade_id` entirely. A failed/empty resolve degrades gracefully to the original selector and bumps the `assign_model_failed` counter on `/connect-metrics` — it never fails the request. Calibrate by capturing one real AssignModel response and confirming the decoded `model_uid` is a sane concrete selector. ### 8.4 Surface billing cost in usage (credit/acu) The response carries `credit_cost`/`committed_credit_cost`/`committed_acu_cost`. Cache token tags are default-on (`5`/`4`). ACU `#22` is still opt-in — free-tier zeros are omitted on the wire, and live paid reconcile is not in this repo. Once known: ```sh # Default already decodes cache_read_tokens=5,cache_write_tokens=4. # ACU field number/type is in the Language Server descriptor (double #22). # Still opt-in: protobuf omits zeros; live paid reconcile is not in this repo. DEVIN_CONNECT_BILLING_TAGS=cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22 ``` `^` means top-level of `GetChatMessageResponse`, not the `#7` metadata sub-message. Unset `DEVIN_CONNECT_BILLING_TAGS` keeps the cache-only default. Set to `off` to decode nothing. ### 8.5 Discover unknown metadata tags (the calibration master-key) §8.4 and §8.6 both need integer tags that only appear on a paid/cached response. The discovery tool is a single env flag — it dumps every varint subfield of the #7 metadata sub-message to the log so you can read the tags straight off a real capture: ```sh DEVIN_CONNECT_DEBUG_META=1 # log line: DEVIN_CONNECT meta dump (tag=value varints): {"2":389,"3":72,"6":6,...} # #2 = prompt_tokens, #3 = completion_tokens (known). Any NEW tag carrying a # credit/acu cost or a cache-token count is your value to pin below. ``` Free-tier baseline (verified 2026-06-30 on `swe-1-6-slow`): the terminal frame carries only `{2: prompt, 3: completion, 6: provider}` — no cost, no cache tokens (free tier doesn't bill or cache, and zero-valued protobuf fields aren't encoded). That's exactly why §8.4/§8.6 are paid-only. ### 8.6 Surface prompt-cache tokens in usage `ModelUsageStats` carries `cache_read_tokens` / `cache_write_tokens` (recon verified field names). Absent on free tier (no caching). Live default already pins `5`/`4`; unset `DEVIN_CONNECT_BILLING_TAGS` keeps that map. Set `off` to decode nothing. The decoder routes cache_* into `usage` (OpenAI-standard shapes) instead of billing: ```sh # Paid-verified (live default): cache_read=5, cache_write=4. # Do not copy the pre-calibration guess credit_cost=6 / cache=14/15. DEVIN_CONNECT_BILLING_TAGS=cache_read_tokens=5,cache_write_tokens=4 ``` `usage` then gains `prompt_tokens_details.cached_tokens` (from cache_read), `fresh_input_tokens` (from prompt) and `cache_creation_input_tokens` (from cache_write). Unset = the cache-only default. Set `off` = no cache counters decoded; `prompt_tokens_details.cached_tokens: 0` and `fresh_input_tokens` still come from the prompt/completion fields, which are always decoded. **`prompt_tokens` is the full input on this path** (O2, measured 2026-10-25): `fresh + cache_read + cache_write`, so a cache-WRITE turn no longer reports the tiny fresh remainder as the prompt (omp showed "3 in" for an 11.5k prompt on 2/7 turns — exactly the two prefix-miss turns). `total_tokens = prompt_tokens + completion_tokens` always; `WINDSURFAPI_STRICT_USAGE_TOTAL` is a no-op here. The Cascade path keeps the #118 split (cache_write outside `prompt_tokens`), which is why consumers must read `fresh_input_tokens` instead of re-deriving `prompt_tokens - cached_tokens`. ### 8.7 finish_reason calibration (already live, free-tier safe) `mapFinishReason` maps the upstream `StopReason` enum (recon variant order: `end_turn, max_tokens, max_turn_requests, refusal, cancelled`) to the OpenAI vocabulary. **Two integers are LIVE-ANCHORED and must not be changed without a capture that contradicts them:** | value | maps to | evidence | |---|---|---| | `2` | `stop` | free-tier normal completion (verified) | | `4` | `stop` | **paid teams** normal completion, calibrated 2026-07-27 — three probes on claude-sonnet-4.6 (`max_tokens` 300 / 8 / 40) all returned 4, and the `max_tokens=300` one answered "HI" (2 chars, unambiguously complete) | `mapFinishReason` reads the descriptor-backed `STOP_REASON_DEFAULT` table (`src/devin-connect.js:2884-2899`) and applies `DEVIN_CONNECT_STOP_REASON_MAP` overrides on top (`:2920-2938`). The shipped map is: | value | maps to | note | |---|---|---| | `0` | `stop` | `STOP_REASON_UNSPECIFIED` | | `1` | `length` | `INCOMPLETE` | | `2` | `stop` | **live-anchored** free-tier normal completion (verified) | | `3` | `length` | `MAX_TOKENS` | | `4` | `stop` | **live-anchored** paid teams normal completion, calibrated 2026-07-27 — three probes on claude-sonnet-4.6 (`max_tokens` 300 / 8 / 40) all returned 4, and the `max_tokens=300` one answered "HI" (2 chars, unambiguously complete) | | `5` | `length` | `MAX_NEWLINES` | | `6` | `stop` | `EXIT_SCOPE` | | `7` | `stop` | `NONFINITE_LOGIT_OR_PROB` | | `8` | `stop` | `FIRST_NON_WHITESPACE_LINE` | | `9` | `length` | `PARTIAL` | | `10` | `tool_calls` | `FUNCTION_CALL` | | `11` | `content_filter` | `CONTENT_FILTER` | | `12` | `stop` | `NON_INSERTION` | | `13` | `error` | `ERROR` | Only values **outside 0–13** (or non-numeric) fall back to `stop` (`src/devin-connect.js:2994-3002`, logged once per distinct value). The earlier wording — "every other value maps to `stop`", with `3 → length` and `5 → content_filter` described as retired guesses — was wrong: `3 → length` and `5 → length` are live, and `5` is `MAX_NEWLINES`, not `CONTENT_FILTER` (`11`). A spurious `length` or `content_filter` closes a `/v1/responses` turn as `response.incomplete` (a whole-turn hard failure for Codex-style clients), and `content_filter` additionally surfaces on `/v1/messages` as Anthropic `stop_reason:'refusal'` — reporting a completed answer as the model refusing. **Truncation is detected from usage, not from the enum.** `resolveFinishReason` reports `length` when `completion_tokens` lands exactly on the cap the caller requested — the same test an OpenAI client would apply itself, and one that does not depend on un-calibrated integers. The check is conservative by design (exact equality, and only when the caller set a cap), because reporting a complete answer as truncated is the more harmful error: clients that auto-continue on `length` would loop on a finished response. An operator with a real capture is a better authority than either mechanism and can override any integer — the value must be one of `stop` / `length` / `tool_calls` / `content_filter` (`src/devin-connect.js:2927`) — and an explicit override for a value also takes precedence over the usage inference: ```sh # Example: treat INCOMPLETE (1) and ERROR (13) as clean stops. DEVIN_CONNECT_STOP_REASON_MAP="1=stop,13=stop" ``` The old example `"1=stop,2=stop,3=length,5=content_filter"` was wrong twice: it mapped `5` (`MAX_NEWLINES`) to `content_filter`, and it presented default entries as if they were calibrations. `11` is `CONTENT_FILTER`; `8` is `FIRST_NON_WHITESPACE_LINE → stop`. Unknown values always fall back to `stop` so a finished stream is never an error. Note: both historical probes (free tier 16 vs 1000, paid 16 vs 4096) saw the cap ignored — but BOTH ran while the encoder had `max_tokens` on the wrong field (#3, which is `max_newlines`; corrected 2026-08-09, see §8.9). Neither probe therefore measured `max_tokens` at all. Re-run them before concluding anything about upstream enforcement, and note that a genuinely truncated turn is what the `3 → length` / `5 → length` entries describe; change them only from a real capture. ### 8.8 Live re-login recovery check (real credentials, zero-billable) The auto-recovery chain (#23/#24/#27) is proven end-to-end against a REAL login two ways. Both are opt-in and billing-free (login + GetUserStatus only): ```sh # unit/integration test — real windsurfLogin + real AES-GCM store: RELOGIN_LIVE=1 RELOGIN_LIVE_EMAIL= RELOGIN_LIVE_PASSWORD=

\ DEVIN_CONNECT_CRED_KEY= \ node --import ./test/setup-env.mjs --test test/devin-connect-relogin-live.test.js # smoke stage — same chain inside the smoke run: SMOKE_RELOGIN_LIVE=1 SMOKE_RELOGIN_EMAIL= SMOKE_RELOGIN_PASSWORD=

\ DEVIN_CONNECT_CRED_KEY= npm run smoke:devin-connect ``` Unarmed (no creds / no flag) both skip cleanly — never a CI failure. Each uses a temp cred store + an ephemeral pool account, so the real `accounts.json` / `accounts.creds.json` are never touched. > **Live-service note (observed 2026-06-30):** a FREE session token can pass > `GetUserStatus` (status alive) yet have its `GetChatMessage` *completion* > rejected with `UNAUTHORIZED an internal error occurred (trace ID:...)`, even > immediately after a fresh login. This is a server-side free-tier condition, not > a client bug — login + status succeed, only the billable completion is walled. > The calibrator (§8.0) handles it gracefully: it reports the probe error, marks > every target `pending`, and writes nothing. Retry with a paid token to capture. ### 8.8b Surface actual_model_uid (router resolution signal) For router selectors (`adaptive`/`arena-*`, see §8.3) the response carries `actual_model_uid` — the concrete model that actually served the turn. Useful to verify AssignModel resolved sanely. It rides the `#7` metadata sub-message at inner tag `9` (frame-verified 2026-07-05, paid teams). It is not a top-level string. Unset = null. To surface it: ```sh # #7 inner tag 9, frame-verified 2026-07-05 (paid teams). Not top-level #13. DEVIN_CONNECT_ACTUAL_MODEL_TAG=9 ``` The streaming `finish` event then carries `actualModel`. It is NOT echoed into the client-facing response `model` field (clients often assert it equals the requested name); it's a verification/observability signal. Unset = null. ### 8.9 Sampling passthrough + temperature=0 clamp (LIVE-VERIFIED) Client sampling controls (`temperature`, `top_p`, `top_k`, `max_tokens`) now flow from the OpenAI/Anthropic request into the connect `CompletionConfig`. Previously they were silently dropped on the DEVIN_CONNECT path — every call ran at built-in defaults. No env flag; on whenever the caller sends them. **temperature=0 clamp**: live testing (free `swe-1-6-slow`, 2026-06-30) showed an exact `temperature=0` reliably makes the upstream return "an internal error occurred" (3/3), while `0.001` succeeds. Since OpenAI clients routinely send `0` for deterministic output, `buildCompletionConfig` clamps any sub-`0.001` temp up to `MIN_TEMPERATURE=0.001` (nearest-greedy the server accepts) rather than letting the call hard-fail. Verified: temp=2 → "Elephant!", temp≈0 → stable "Dog"/"Cat", proving sampling genuinely reaches the model. TAG MAP CORRECTED (2026-08-09): `max_tokens` is field **#2**, not #3 — #3 is `max_newlines`. Four independent `.proto` definitions of `CompletionConfiguration` (reverse-engineered by unrelated projects) agree, and the encoder now matches (`src/devin-connect.js` `buildCompletionConfig`, pinned byte-level by `test/completion-config-max-tokens-tag.test.js`). The old layout sent the 128000 context window as `max_tokens`, i.e. the cap was pinned wide open. STILL UNRESOLVED: the swap explains the free-tier probe (16 vs 1000 → identical output: the cap never reached the enforcing field) but it does **not** explain the paid re-check recorded in §8.7 (16 vs 4096 → byte-identical output). The paid tier is where a cap should be honoured, so that data point remains unaccounted for. Treat "max_tokens is now enforceable" as a schema-backed expectation, NOT a measured fact, until a paid capture shows a request with a small cap actually truncating. The change is wire-safe either way: a large `max_newlines` is a no-op. ### 8.10 Native tool definitions (groundwork, paid-calibration gated) The calibrated proto verifies the repeated `tools` field at request #10, but the inner ToolDef subfield layout (name/desc/schema tags) was never decoded from a free capture. So native tool defs are OFF by default — tools keep riding the prompt via emulation, which works on every model. The request builder forwards `tools` and self-gates: nothing is emitted unless the inner tags are calibrated. To enable after a paid capture (§8.5) reveals the ToolDef inner tags: ```sh DEVIN_CONNECT_TOOL_DEF_TAGS="outer=10,name=1,description=2,parameters=3" ``` Named form (the supported one; the legacy positional `10,1,2,3` is back-compat only and must be exactly four numbers). Order: outer repeated tag (verified =10), then the name/description/schema subfield tags. Malformed or wrong-arity → fails closed to emulation (never a broken frame). Live-verified: forwarding tools with the gate OFF produces an identical, working request (zero regression). Response-side `ChatToolCall` decoding (`arguments_json`/`id`/`invalid_json_str`) still needs a paid capture and is tracked separately under #49. **Response-side decode** is wired the same way, gated by `DEVIN_CONNECT_TOOL_CALL_TAGS`. Once a paid frame dump (§8.5, `DEVIN_CONNECT_DEBUG_META=1`) reveals the repeated `delta_tool_calls` tag and the ChatToolCall subfields: ```sh DEVIN_CONNECT_TOOL_CALL_TAGS="outer=12,id=4,name=1,arguments_json=3" ``` `outer` (the repeated-field tag) is mandatory; the streaming `finish` event then carries `toolCalls: [{ id, name, arguments }]`. Off (default) → `toolCalls` is null and prompt emulation (`parseToolCallsFromText`) owns tool calls on every model, exactly as today. The decode is a pure read; an uncalibrated or absent tag map has zero behavioral effect. ### 8.11 Session continuity (`DEVIN_CONNECT_SESSION_REUSE`) — default OFF Every DEVIN_CONNECT request carries a protobuf session id in field `#16`. With the switch unset (the shipped default), the handler mints a fresh `randomUUID()` per call, so one multi-turn dialog looks like N brand-new sessions to the upstream velocity limiter and prompt-cache reuse depends on prefix luck. Measured 2026-10-25 (omp 18.12.6, 7 requests, 3 conversations): **7/7 distinct session ids**, including three consecutive turns of one conversation. That is the documented default behaviour, not a lost client path — the client never sends a session id. With reuse ON, `src/session-continuity.js` derives a stable id from the conversation's own completed request/response pair chain (never from requestId/IP/timestamp); parallel dialogs on the same key stay independent. In-memory only, 30-minute TTL, 500 tracked dialogs by default: ```sh DEVIN_CONNECT_SESSION_REUSE=1 # Optional features — both require the main gate: DEVIN_CONNECT_MODEL_CONFIG_STABLE=0 # stable #15.1 + monotonic #15.2 DEVIN_CONNECT_SESSION_REASONING_INJECT=0 # Store sizing (only meaningful while reuse is on): DEVIN_CONNECT_SESSION_TTL_MS=1800000 DEVIN_CONNECT_SESSION_MAX_STATES=500 ``` Turning it on is a POLICY change, not a bug fix: the upstream session-limit behaviour behind it is why the default stayed off. The 2026-10-25 capture did NOT run with the gate on, so its effect on cache-hit rate and velocity limiting is `[INFERENCE]` from the code path, not a measurement. **Observability (added with O2/O3).** The handler now always names the source: reuse ON logs `DEVIN_CONNECT session reuse active → session_id=… acct=…`; reuse OFF logs `DEVIN_CONNECT session_id source=fresh-per-request (DEVIN_CONNECT_SESSION_REUSE off …)` once at info and per request at debug. A default deployment can therefore tell "continuity is off" from "reuse was expected but missed".