Ctrl K
ComunidadGuías…

src/devin-connect.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * DEVIN_CONNECT — direct cloud GetChatMessage adapter (pure HTTP egress).
 *
 * This is the production path that lets the proxy reach Windsurf's hosted chat
 * backend WITHOUT a local Devin CLI subprocess. It speaks the same Connect-RPC
 * wire protocol the CLI uses against server.codeium.com, so it works anywhere
 * the box has the Windsurf session token (e.g. prod containers with no CLI).
 *
 * The full request/response wire format was calibrated against live captures
 * (see memory: devin-connect-WORKING-recipe-2026-06-30). The two non-obvious
 * bits that gate the whole flow:
 *
 *   1. AUTH header is the session token *doubled*, dash-joined:
 *        authorization: Basic <token>-<token>
 *      A single token is rejected with permission_denied. The proto-body copy
 *      (ClientMetadata.session_token, field #3) stays SINGLE.
 *
 *   2. ClientMetadata fingerprint (field #31) must be 732 hex chars (366 bytes).
 *      A short value trips a server-side "internal" error. The value itself is
 *      not session-bound — a fresh random hex string is accepted.
 *
 * Endpoint : POST https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage
 * Transport: Connect-RPC, application/connect+proto, single request envelope,
 *            multi-frame streaming response (answer text in response field #3;
 *            #9 is the separate reasoning/thinking stream).
 *
 * Zero npm deps. Nothing dials the network at import time.
 */

import https from 'https';
import { StringDecoder } from 'string_decoder';
import { randomUUID, randomBytes, createHmac } from 'crypto';
import * as _wireFs from 'fs';
import * as _wirePath from 'path';
import { log } from './config.js';
import {
  writeMessageField, writeStringField, writeVarintField, writeFixed64Field,
  parseFields, getField, getAllFields,
} from './proto.js';
import { wrapRequest, wrapEnvelope, StreamingFrameParser, connectHeaders, tryGunzip } from './connect.js';
// Observability counter only (mirrors devin-connect-openai.js importing
// recordArgRepair from cline-compat). cc-compat is a zero-I/O, zero-pipeline-
// import pure module, so this cannot create a cycle or perturb the wire path.
import { recordSchemaNormalized } from './handlers/cc-compat.js';
// Observability only, same rationale as recordSchemaNormalized above: the
// metrics module has no imports of its own, so it cannot create a cycle.
import { bumpConnect } from './devin-connect-metrics.js';
// Bounded remote-image fetch (deadline, byte cap, MIME allowlist, per-hop SSRF
// lookup hook). image.js imports only config/pdf/net-safety/vendor codecs, so this
// cannot create a cycle back into the connect pipeline.
import {
  fetchImageUrl,
  MAX_SIZE as MAX_IMAGE_BYTES,
  MAX_BASE64_LEN as MAX_IMAGE_BASE64_LEN,
  MIME_OK as IMAGE_MIME_ALLOWLIST,
} from './image.js';

const HOST = 'server.codeium.com';
const PATH = '/exa.api_server_pb.ApiServerService/GetChatMessage';
// Short-lived user JWT. Four independent .proto reimplementations agree on the
// method and the response shape ({user_jwt=1, custom_api_server_url=2}); the
// credential is HS256 with roughly a 24-minute lifetime and rides the request as
// ClientMetadata #21. See mintUserJwt below for why this ships default-OFF.
const USER_JWT_PATH = '/exa.auth_pb.AuthService/GetUserJwt';
const METADATA_USER_JWT_FIELD = 21;

// Ceiling for an untrusted upstream response body. Generous next to any real
// Connect-RPC error — upstream returns a JSON envelope or a short text line —
// and far below V8's ~512MiB string limit, so the toString() can never be the
// thing that throws. 8MiB leaves room for a pathological proxy error page
// while keeping the process nowhere near the allocator's ceiling.
// Read once at module load rather than per response: this is a safety ceiling,
// not a tuning knob, and the hot path should not re-parse an env var on every
// error. Overridable so the ceiling itself is testable without allocating
// megabytes per case; a non-positive or unparseable value falls back to the
// default rather than disabling the cap, because "no cap" is the bug.
export const MAX_ERROR_BODY_BYTES = (() => {
  const raw = Number.parseInt(String(process.env.DEVIN_CONNECT_MAX_ERROR_BODY_BYTES ?? ''), 10);
  return Number.isSafeInteger(raw) && raw > 0 ? raw : 8 * 1024 * 1024;
})();

// Strict positive-integer env reader (same fallback rule as client.js/auth.js:
// unparseable or non-positive values fall back to the default rather than
// disabling the bound — "no cap" is the bug). Used by the per-stream queue
// caps below; read once per request, never on the per-frame path.
function positiveIntEnv(value, fallback) {
  const n = Number.parseInt(String(value ?? ''), 10);
  return Number.isSafeInteger(n) && n > 0 ? n : fallback;
}
// Appended when the ceiling is hit, so the truncation is visible to whoever
// reads the error rather than the error silently describing a prefix of
// itself. Mirrors RESPONSE_STORE's TRUNCATION_MARKER.
export const ERROR_BODY_TRUNCATION_MARKER = '\n[... truncated: upstream error body exceeded the cap ...]';

/**
 * Bounded accumulator for an upstream response body.
 *
 * EVERY reader that takes an HTTP body off the wire uses this — the streamChat
 * non-200 branch, postConnectUnary (GetUserJwt) and the catalog unary probe
 * (GetCliModelConfigs / GetUserStatus / AssignModel) — so the ceiling and the
 * truncation behavior cannot drift between call sites.
 *
 * Why a ceiling at all: concatenating without one is an availability bug, not
 * a parsing one. A misbehaving or hostile upstream can stream past V8's
 * ~512MiB string limit, at which point Buffer.concat(...).toString('utf8')
 * throws ERR_STRING_TOO_LONG. That throw happens inside the 'end' listener,
 * where nothing catches it: it escapes as an uncaughtException and
 * src/index.js exits the process, so one bad response from one upstream
 * disconnects every tenant. Same shape as the decodeGrpcMessage guard in
 * grpc.js:21-34.
 *
 * Keep a genuine prefix rather than whole chunks. Slicing the chunk that
 * crosses the ceiling is what makes this correct for a body that arrives as
 * one large chunk: dropping that chunk instead would leave nothing behind and
 * the error would be a bare truncation marker describing no upstream output at
 * all. The tail is discarded, never accumulated.
 */
export function createBoundedBodyAccumulator() {
  const chunks = [];
  let bytes = 0;
  let truncated = false;
  return {
    onData(chunk) {
      if (truncated) return;
      const room = MAX_ERROR_BODY_BYTES - bytes;
      if (chunk.length <= room) {
        bytes += chunk.length;
        chunks.push(chunk);
        return;
      }
      if (room > 0) chunks.push(chunk.subarray(0, room));
      bytes = MAX_ERROR_BODY_BYTES;
      truncated = true;
    },
    /** @returns {{ raw: Buffer, truncated: boolean }} */
    finish() {
      return { raw: Buffer.concat(chunks), truncated };
    },
  };
}

function validatedAccountConnectHost(host) {
  const refused = () => Object.assign(new Error('ERR_CONNECT_ACCOUNT_HOST_NOT_ALLOWED'), {
    code: 'ERR_CONNECT_ACCOUNT_HOST_NOT_ALLOWED',
  });
  // The URL parser normalizes whitespace and backslashes. Reject those inputs
  // explicitly, along with an empty/userinfo authority, before using its host.
  if (typeof host !== 'string' || /[\s\\]/.test(host)) throw refused();
  let url;
  try { url = new URL(host); }
  catch { throw refused(); }
  // Only the chat origin demonstrated by this tree is authorized to receive
  // a live session token. Do not infer trust from a suffix or imported URL.
  // WHATWG normalizes an explicit HTTPS :443 to an empty port.
  if (url.protocol !== 'https:' || url.hostname !== HOST || url.port !== ''
      || url.username !== '' || url.password !== '' || host.includes('@')
      || url.pathname !== '/' || url.search !== '' || url.hash !== '') {
    throw refused();
  }
  return url.hostname;
}

// ─── Wire capture (RE / vision analysis, gated) ─────────────────────────────
// When DEVIN_CONNECT_WIRE_DUMP=1, drop the raw upstream GetChatMessage
// request/response bytes to disk so the exact protobuf (thinking #12, vision,
// tool_call tags) can be analyzed offline. Default OFF = zero overhead; the dir
// defaults to <cwd>/.wire-dump. Filenames carry a timestamp + model + kind. This
// is a temporary self-use debug aid, never a served endpoint.
let _wireSeq = 0;
let _wireDumpWarned = false;
function dumpWire(kind, bytes, meta = {}) {
  const wireDump = String(process.env.DEVIN_CONNECT_WIRE_DUMP || '') === '1';
  const traced = String(process.env.WINDSURFAPI_TRACE || '') === '1' && meta.traceId;
  if (!wireDump && !traced) return;
  if (!bytes || !bytes.length) return;
  // S6-F2: the dumped request protobuf carries the live account session token
  // (metadata field #3) in cleartext. The dirs are gitignored, but say so once
  // so an operator who greps a dump later knows what they are holding.
  if (!_wireDumpWarned) {
    _wireDumpWarned = true;
    log.warn('wire-dump/trace active: dumped protobufs carry the live account session token — treat .wire-dump/.trace as credential material and delete after use');
  }
  try {
    // Full-chain trace mode: drop the raw bytes into this request's trace dir
    // under the fixed leg name (03=req, 04=res) the manifest expects, so the
    // Devin wire bytes sit next to 01-client-req / 02-routing / 05-client-res.
    if (traced) {
      const root = process.env.WINDSURFAPI_TRACE_DIR || _wirePath.resolve(process.cwd(), '.trace');
      const tdir = _wirePath.join(root, String(meta.traceId).replace(/[^\w.-]/g, '_'));
      _wireFs.mkdirSync(tdir, { recursive: true });
      const leg = kind === 'req' ? '03-upstream-req' : '04-upstream-res';
      _wireFs.writeFileSync(_wirePath.join(tdir, `${leg}.bin`), bytes);
      if (meta.note) _wireFs.writeFileSync(_wirePath.join(tdir, `${leg}.txt`), meta.note);
    }
    // Standalone wire-dump mode (offline RE, no trace correlation needed).
    if (wireDump) {
      const dir = process.env.DEVIN_CONNECT_WIRE_DUMP_DIR || _wirePath.resolve(process.cwd(), '.wire-dump');
      _wireFs.mkdirSync(dir, { recursive: true });
      const seq = String(++_wireSeq).padStart(4, '0');
      const model = String(meta.model || 'model').replace(/[^\w.-]/g, '_').slice(0, 40);
      const stamp = new Date().toISOString().replace(/[:.]/g, '-');
      const base = `${stamp}-${seq}-${model}-${kind}`;
      _wireFs.writeFileSync(_wirePath.join(dir, `${base}.bin`), bytes);
      if (meta.note) _wireFs.writeFileSync(_wirePath.join(dir, `${base}.txt`), meta.note);
    }
  } catch (e) {
    log.warn(`wire-dump failed: ${e.message}`);
  }
}

// Transport seam: defaults to https.request. Swappable in tests so the timeout
// / deadline logic can be exercised against a fake socket without a live call.
let requestImpl = https.request;
export function __setRequestImpl(fn) { requestImpl = fn || https.request; }

// ClientMetadata constants observed on every live CLI request. "chisel" is the
// CLI's internal client name; the version string tracks the Devin CLI build.
const CLIENT_NAME = 'chisel';
const CLIENT_VERSION = '2026.8.18';

// ChatMessage.source enum (field #2). Mirrors windsurf.js SOURCE — only the
// values this path actually emits are listed. TOOL_RESULT (4) and its matching
// tool_call_id #7 are verified by native tool-history captures; ordinary image
// input remains source=USER and uses repeated #10 on that same message.
const SOURCE = Object.freeze({ USER: 1, ASSISTANT: 2, TOOL_RESULT: 4 });

// CompletionConfig defaults, matched to the captured CLI request.
// #2 = max_tokens, #3 = max_newlines (schema-confirmed); see buildCompletionConfig.
const DEFAULT_MAX_NEWLINES = 128000;
const DEFAULT_MAX_TOKENS = 4096;
// What goes on the wire at #2 when the caller named no cap. Deliberately NOT
// DEFAULT_MAX_TOKENS: since the tag fix, #2 is an ENFORCED output cap, and a defaulted
// call must not be quietly capped below the repo's own output convention (config.js
// MAX_TOKENS=8192, and the `|| 8192` fallbacks in handlers/messages.js + gemini.js).
// Overridable so an operator can raise or lower it without a code change.
const WIRE_DEFAULT_MAX_TOKENS = (() => {
  const n = Number(process.env.DEVIN_CONNECT_WIRE_MAX_TOKENS);
  return Number.isInteger(n) && n > 0 ? n : 8192;
})();
const DEFAULT_TEMPERATURE = 1.0;
// Smallest temperature the upstream accepts; exactly 0 → server "internal error"
// (live-verified). Callers asking for 0 (greedy) get clamped to this instead.
const MIN_TEMPERATURE = 0.001;
const DEFAULT_TOP_K = 40;
const DEFAULT_TOP_P = 0.95;

/** IEEE-754 double, little-endian — for CompletionConfig temp/top_p (wire type 1). */
function f64le(value) {
  const b = Buffer.alloc(8);
  b.writeDoubleLE(value, 0);
  return b;
}

/**
 * Resolve the session token. Mirrors the convention in devin-backend.js:
 * DEVIN_CONNECT_TOKEN wins, then the shared WINDSURF_API_KEY. The value is the
 * raw `devin-session-token$<JWT>` string; never logged.
 */
export function getConnectToken(env = process.env) {
  return String(env.DEVIN_CONNECT_TOKEN || env.WINDSURF_API_KEY || '').trim();
}

/**
 * Generate the fingerprint for ClientMetadata #31. The server only checks the
 * length/shape (732 hex chars = 366 bytes), NOT the value (see file header
 * §2 + the length-only check in this file), so both a per-request random hex
 * string and a stable per-account one are accepted at the wire level.
 *
 * @param {string} [deviceSeed] — when provided (per-account stable device mode,
 *   opt-in), the 366 bytes are DERIVED deterministically from the seed via an
 *   HMAC-SHA256 counter (HKDF-style expand), so the same account presents the
 *   same device fingerprint on every request. When absent (default), fall back
 *   to the historical per-request `randomBytes(366)` — BYTE-IDENTICAL behavior,
 *   so the default path is unchanged. The choice between the two is gated by the
 *   caller (WINDSURFAPI_STABLE_DEVICE + a per-account deviceSeed), never here.
 */
function generateFingerprint(deviceSeed) {
  if (!deviceSeed) return randomBytes(366).toString('hex'); // default: per-request random
  return deriveDeviceBytes(deviceSeed, 'devin-clientmeta', 366).toString('hex');
}

/**
 * HKDF-style expand: stretch a seed into `len` bytes deterministically using
 * HMAC-SHA256 in counter mode (RFC 5869 expand phase, no salt). Same (seed,
 * info, len) → same bytes; different accounts (different seeds) → different
 * bytes; the `info` label namespaces distinct fingerprints from one seed so the
 * #31 metadata and, later, the login UA cannot be cross-correlated or reversed.
 */
function deriveDeviceBytes(seed, info, len) {
  const out = [];
  let produced = 0;
  let counter = 0;
  let prev = Buffer.alloc(0);
  while (produced < len) {
    counter++;
    const h = createHmac('sha256', String(seed));
    h.update(prev);
    h.update(Buffer.from(info, 'utf8'));
    h.update(Buffer.from([counter & 0xff]));
    prev = h.digest();
    out.push(prev);
    produced += prev.length;
  }
  return Buffer.concat(out).subarray(0, len);
}

// ── File/document content (P-03) ─────────────────────────────────────────
//
// The GetChatMessage ChatMessage wire has NO attachment field. Its calibrated
// shape is {uuid #1, role #2, text #3, tool_call_id #7, images #10} plus the
// optional reasoning tag (see encodeChatMessage below); `text` is a single
// string. So a typed file/document block cannot be "preserved through the
// builder": there is nowhere on the wire to put it. `messageText()` used to
// filter only `{type:'text'}` parts, which turned a Responses `input_file`
// (or Chat `file`) block into '' — the model answered the instruction without
// the document and no layer reported the loss.
//
// Cascade is different: image.js `extractImages` turns PDF file blocks into
// text before the prompt is built, so responses.js deliberately keeps the
// typed block. This module is the Connect boundary and must refuse instead of
// silently flattening. The handler raises the 400 before account acquisition;
// buildGetChatMessageRequest repeats the check for direct callers.

const FILE_CONTENT_TYPES = new Set(['file', 'input_file', 'document']);
const NON_FILE_CONTENT_TYPES = new Set([
  'text', 'input_text', 'output_text', 'image', 'image_url', 'input_image',
  'input_audio', 'audio', 'thinking', 'tool_use', 'tool_result',
]);

/** True for a content block carrying a file/document payload. */
export function isFileContentPart(part) {
  if (!part || typeof part !== 'object') return false;
  const type = String(part.type || '').toLowerCase();
  if (FILE_CONTENT_TYPES.has(type)) return true;
  // Known text/image/tool blocks are never file payloads, even if a client
  // attaches an unrelated `file` property.
  if (NON_FILE_CONTENT_TYPES.has(type)) return false;
  // Shape-only / defensive variants: {file:{...}} with no type (or an unknown
  // type), or the Responses `file_data` / `file_id` fields at the top level.
  if (part.file && typeof part.file === 'object') return true;
  if (typeof part.file_data === 'string' && part.file_data) return true;
  if (typeof part.file_id === 'string' && part.file_id) return true;
  return false;
}

/** First file/document block in a content array, or null. */
export function findFileContentPart(content) {
  if (!Array.isArray(content)) return null;
  for (const part of content) {
    if (isFileContentPart(part)) return part;
  }
  return null;
}

/** First file/document block anywhere in a [{role, content}] message list. */
export function findFilePartInMessages(messages) {
  if (!Array.isArray(messages)) return null;
  for (const msg of messages) {
    const found = findFileContentPart(msg?.content);
    if (found) return found;
  }
  return null;
}

/**
 * Stable 400-class error for a file/document block the Connect wire cannot
 * carry. `param` names the request field the caller must change: `input` on
 * /v1/responses, `messages` on /v1/chat/completions.
 */
export function unsupportedFilePartError(part, { param = 'input' } = {}) {
  const type = String(part?.type || 'file').toLowerCase();
  const rawName = part?.file?.filename || part?.filename || part?.file?.name || '';
  const name = String(rawName).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 120);
  const label = name ? `${type} "${name}"` : type;
  const error = new Error(
    `Unsupported content part (${label}): the Devin Connect backend carries only text and images `
    + '(ChatMessage has no file/document field), so this payload cannot reach the model. '
    + `Remove it from \`${param}\` or use a backend/model that supports document input.`,
  );
  error.code = 'unsupported_input_file';
  error.param = param;
  error.status = 400;
  return error;
}

/**
 * Flatten an OpenAI-style message content into plain text. Cloud GetChatMessage
 * takes a single string per ChatMessage; structured/tool content is degraded to
 * text the same way the legacy Raw path does (see windsurf.js).
 *
 * File/document parts are NOT silently flattened (P-03): they would become ''
 * here while `hasMultimodalContent`/the answerability guard still counted them
 * as content. They throw the stable unsupported-input error instead.
 */
export function messageText(content) {
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    const filePart = findFileContentPart(content);
    if (filePart) throw unsupportedFilePartError(filePart);
    return content.filter(c => c?.type === 'text').map(c => c.text).join('\n');
  }
  if (content == null) return '';
  if (isFileContentPart(content)) throw unsupportedFilePartError(content);
  return JSON.stringify(content);
}

// Image (vision) support — VERIFIED-FROM-WIRE against real Devin clients.
//
// The `images` field lives NESTED inside each ChatMessage at TAG #10 (confirmed
// from the captured protobuf: ChatMessage {uuid #1, role #2, text #3,
// tool_call_id #7, images #10}). Each images entry is an ImageData sub-message
// { base64_data=1, mime_type=2 } — the SAME inner shape as the Cascade path
// (earlier RE guesses of 3,4 were wrong). A 2026-07-30 native SWE-1.7 capture
// proves ordinary multimodal input stays on source=USER with repeated images
// #10; synthesizing an assistant `read` tool call changes the conversation and
// fails on models whose assistant turns require a server-issued signature.
//
// The VERIFIED tag is 10. It is now the default: the extracted Devin schema and
// a native SWE-1.7 request/response both confirm that ordinary user images ride
// directly on the source=USER ChatMessage at repeated #10. Operators can still
// set DEVIN_CONNECT_IMAGE_TAG=0 to disable emission, or point the tag elsewhere
// temporarily when validating a future upstream wire change.
export const VERIFIED_IMAGE_TAG = 10;
export function getImageFieldTag(env = process.env) {
  const raw = String(env.DEVIN_CONNECT_IMAGE_TAG ?? '').trim();
  if (!raw) return VERIFIED_IMAGE_TAG;
  const tag = Number.parseInt(raw, 10);
  return Number.isInteger(tag) && tag > 0 && tag < 536870912 ? tag : 0;
}

/**
 * Pull data-URL / base64 image blocks out of OpenAI-style message content,
 * yielding the same { base64_data, mime_type } shape the Cascade ImageData
 * encoder consumes. Synchronous: only inline data-URL / base64 sources are
 * handled here. http(s) sources are resolved BEFORE the builder runs, by
 * `inlineRemoteImages` in streamChat(); a URL that still reaches this function
 * is named by the builder's unresolved-URL pass instead of vanishing silently.
 */
export function extractInlineImages(content) {
  if (!Array.isArray(content)) return [];
  const images = [];
  for (const block of content) {
    if (!block || typeof block !== 'object') continue;
    if (block.type === 'image') {
      const src = block.source || {};
      const mime = String(src.media_type || '').toLowerCase();
      // PDFs are text-extracted upstream, not sent as images.
      if (mime === 'application/pdf') continue;
      if ((src.type === 'base64' || !src.type) && src.data) {
        images.push({ base64_data: src.data, mime_type: src.media_type || 'image/png' });
      }
    } else if (block.type === 'image_url') {
      const url = block.image_url?.url || '';
      const m = url.replace(/\s/g, '').match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
      if (m) images.push({ base64_data: m[2], mime_type: m[1].toLowerCase() });
    }
  }
  return images;
}

/**
 * True for a content block that would ride the connect wire as an image.
 * Mirrors the two extractors (extractInlineImages above, remoteImageUrl below)
 * rather than a looser `type.startsWith('image')`: a PDF dressed as an Anthropic
 * image block is a document (extractInlineImages skips it), and a bare
 * `image_url` string without `.url` extracts to nothing.
 */
function isWireImageBlock(block) {
  if (!block || typeof block !== 'object') return false;
  const type = String(block.type || '').toLowerCase();
  if (type === 'image') {
    const src = block.source || {};
    const mime = String(src.media_type || '').toLowerCase();
    if (mime === 'application/pdf') return false;
    return (src.type === 'base64' || !src.type) && !!src.data;
  }
  if (type === 'image_url') {
    const url = block.image_url?.url;
    return typeof url === 'string' && url.length > 0;
  }
  // Responses-API shape; responses.js normalises it to image_url before the chat
  // handler, but a direct caller of this helper should still lose the image.
  if (type === 'input_image') {
    const raw = block.image_url;
    if (typeof raw === 'string') return raw.length > 0;
    return typeof raw?.url === 'string' && raw.url.length > 0;
  }
  return false;
}

/**
 * Remove wire-bound image blocks from OpenAI/Anthropic-shaped messages.
 *
 * Used when the live catalog EXPLICITLY declares the resolved selector
 * text-only (supports_images=false): the text turn is preserved, the images are
 * not sent, and the caller counts what was dropped. System turns are skipped —
 * they are hoisted into the text-only system_prompt field (#2), so an image
 * there was never going to ride the wire and counting it would be a false drop.
 *
 * Copy-on-write: the caller's array and message objects are never mutated (the
 * same params object is re-passed on retry/failover). Returns the input array
 * unchanged when nothing was dropped.
 *
 * @param {Array} messages
 * @returns {{messages: Array, dropped: number}}
 */
export function stripImageParts(messages) {
  if (!Array.isArray(messages) || messages.length === 0) return { messages, dropped: 0 };
  let out = null;
  let dropped = 0;
  for (let i = 0; i < messages.length; i++) {
    const msg = messages[i];
    if (!msg || msg.role === 'system' || !Array.isArray(msg.content)) continue;
    const kept = msg.content.filter((block) => !isWireImageBlock(block));
    const removed = msg.content.length - kept.length;
    if (!removed) continue;
    dropped += removed;
    if (!out) out = messages.slice();
    out[i] = { ...msg, content: kept };
  }
  return { messages: out || messages, dropped };
}

// ─── Remote image_url resolution (http/https) ───────────────────────────────
//
// `extractInlineImages` is SYNCHRONOUS and only understands data: URLs, so an
// ordinary OpenAI `image_url` block pointing at https://… used to be discarded
// without a trace: the text reached the model, the picture did not, and nothing
// was logged. Measured 2026-10-23 on all four connect families
// (evidence/ToolMatrixLive.md §images): inline PNG answered "Red"/"Green" while
// the same PNG behind an HTTPS URL answered "Unknown"/"cannot see".
//
// The wire builder has to stay synchronous (dozens of call sites and wire tests
// depend on that), so the fetch happens once, here, in streamChat() — the only
// production caller of the builder — before the proto is assembled. Each URL is
// resolved through image.js's `fetchImageUrl`, which already carries every bound
// this needs: an 8 s absolute deadline shared across redirect hops, a 5 MB body
// cap, an image/* content-type allowlist, http(s)-only, and a DNS lookup hook
// that refuses private/loopback/link-local targets on EVERY hop (net-safety.js
// `isPrivateIp`). Reusing it keeps one SSRF policy in the tree instead of a
// second, subtly different one on the connect route.
//
// Bounds beyond the per-fetch ones: at most REMOTE_IMAGE_MAX_PER_REQUEST fetches
// per request, sequential, sharing one REMOTE_IMAGE_FETCH_BUDGET_MS wall-clock
// budget — a caller cannot turn one chat request into an unbounded stream of
// outbound connections. Failures are logged (host only: URLs may carry signed
// query strings), counted, and the image is dropped; the text still goes
// upstream.
export const REMOTE_IMAGE_MAX_PER_REQUEST = 20;
export const REMOTE_IMAGE_FETCH_BUDGET_MS = 8000;

function imageAbortError() {
  const err = new Error('The operation was aborted');
  err.name = 'AbortError';
  err.code = 'ABORT_ERR';
  return err;
}

/** The http(s) URL of an image_url block, or '' for every other block shape. */
function remoteImageUrl(block) {
  if (!block || typeof block !== 'object' || block.type !== 'image_url') return '';
  const url = block.image_url?.url;
  return typeof url === 'string' && /^https?:\/\//i.test(url) ? url : '';
}

/** Host only — never log a full URL, which may carry a signed query string. */
function imageUrlHost(url) {
  try { return new URL(url).host || '(unparseable)'; } catch { return '(unparseable)'; }
}

// Reject with AbortError as soon as the caller aborts, without waiting for the
// fetch's own deadline. The underlying fetch keeps its promise handlers attached
// (no unhandled rejection) and still tears its socket down at the 8 s deadline.
function withAbort(promise, signal) {
  if (!signal) return promise;
  if (signal.aborted) return Promise.reject(imageAbortError());
  return new Promise((resolve, reject) => {
    const onAbort = () => reject(imageAbortError());
    signal.addEventListener('abort', onAbort, { once: true });
    promise.then(
      (value) => { signal.removeEventListener('abort', onAbort); resolve(value); },
      (err) => { signal.removeEventListener('abort', onAbort); reject(err); },
    );
  });
}

/**
 * Replace every http(s) `image_url` block with an equivalent data URL, fetching
 * it through a bounded transport. Returns a NEW messages array when anything was
 * fetched or dropped (the caller's array and message objects are never mutated —
 * the same params are re-passed on retry/failover), otherwise the input as-is.
 *
 * `fetchImpl` is injectable for tests; it must resolve { base64_data, mime_type }.
 *
 * @param {Array} messages OpenAI-shaped messages
 * @param {{ signal?: AbortSignal, fetchImpl?: Function }} [opts]
 * @returns {Promise<Array>}
 */
export async function inlineRemoteImages(messages, { signal, fetchImpl = fetchImageUrl } = {}) {
  if (!Array.isArray(messages) || messages.length === 0) return messages;
  if (signal?.aborted) throw imageAbortError();

  const deadline = Date.now() + REMOTE_IMAGE_FETCH_BUDGET_MS;
  let fetched = 0;
  let out = null; // copy-on-write: untouched arrays stay shared with the caller

  for (let i = 0; i < messages.length; i++) {
    const msg = messages[i];
    // System turns are hoisted into field #2 as text; they have no image slot.
    if (!msg || msg.role === 'system' || !Array.isArray(msg.content)) continue;
    if (!msg.content.some((b) => remoteImageUrl(b))) continue;

    const blocks = [];
    let changed = false;
    for (const block of msg.content) {
      const url = remoteImageUrl(block);
      if (!url) { blocks.push(block); continue; }

      if (fetched >= REMOTE_IMAGE_MAX_PER_REQUEST) {
        log.warn(`DEVIN_CONNECT: more than ${REMOTE_IMAGE_MAX_PER_REQUEST} remote image_url blocks in one request — image from ${imageUrlHost(url)} not fetched and dropped`);
        bumpConnect('remote_image_fetch_failed');
        changed = true;
        continue;
      }
      const remaining = deadline - Date.now();
      if (remaining <= 0) {
        log.warn(`DEVIN_CONNECT: remote image fetch budget (${REMOTE_IMAGE_FETCH_BUDGET_MS} ms) exhausted — image from ${imageUrlHost(url)} dropped`);
        bumpConnect('remote_image_fetch_failed');
        changed = true;
        continue;
      }
      fetched++;
      let img;
      try {
        img = await withAbort(
          fetchImpl(url, Math.min(remaining, REMOTE_IMAGE_FETCH_BUDGET_MS)),
          signal,
        );
      } catch (err) {
        if (err?.name === 'AbortError') throw err;
        log.warn(`DEVIN_CONNECT: remote image_url fetch failed (${imageUrlHost(url)}): ${err.message} — image dropped, text preserved`);
        bumpConnect('remote_image_fetch_failed');
        changed = true;
        continue;
      }

      const mime = String(img?.mime_type || '').toLowerCase();
      const data = typeof img?.base64_data === 'string' ? img.base64_data : '';
      // fetchImageUrl already enforces both, but this helper also accepts an
      // injected transport: re-check the RESULT so a different/regressed fetcher
      // cannot smuggle non-image bytes or an oversized payload onto the wire.
      if (!data || !IMAGE_MIME_ALLOWLIST.has(mime)) {
        log.warn(`DEVIN_CONNECT: remote image_url returned unsupported type "${mime || 'none'}" (${imageUrlHost(url)}) — image dropped`);
        bumpConnect('remote_image_fetch_failed');
        changed = true;
        continue;
      }
      if (data.length > MAX_IMAGE_BASE64_LEN) {
        log.warn(`DEVIN_CONNECT: remote image_url exceeds ${MAX_IMAGE_BYTES} bytes (${imageUrlHost(url)}) — image dropped`);
        bumpConnect('remote_image_fetch_failed');
        changed = true;
        continue;
      }

      blocks.push({
        ...block,
        image_url: { ...(block.image_url || {}), url: `data:${mime};base64,${data}` },
      });
      bumpConnect('remote_images_inlined');
      changed = true;
    }

    if (!changed) continue;
    if (!out) out = messages.slice();
    out[i] = { ...msg, content: blocks };
  }
  return out || messages;
}

/** Encode one ImageData sub-message. Inner tags are calibratable via
 * DEVIN_CONNECT_IMAGE_INNER_TAGS="base64,mime" (default "1,2").
 * VERIFIED-FROM-WIRE (req022 CM#5 #10): ImageData is a protobuf submessage
 * { base64_data=#1, mime_type=#2 }, and #1 carries a BASE64 TEXT string (starts
 * "iVBORw0KGgo…" = PNG magic after a base64 pass), NOT raw image bytes — hence
 * writeStringField, not writeBytesField. An earlier RE guess of 3,4 (from a
 * declaration-order reading of {width,height,base64_data,mime_type,source_path})
 * was DISPROVEN by the wire; the calibration hook is retained only as an escape
 * valve, the wire-proven default is 1,2. */
function encodeImageData(img, env = process.env) {
  let bTag = 1, mTag = 2;
  const raw = String(env.DEVIN_CONNECT_IMAGE_INNER_TAGS || '').trim();
  if (/^\d+,\d+$/.test(raw)) {
    const [b, m] = raw.split(',').map((n) => Number.parseInt(n, 10));
    if (b > 0 && m > 0) { bTag = b; mTag = m; }
  }
  return Buffer.concat([
    writeStringField(bTag, img.base64_data),
    writeStringField(mTag, img.mime_type || 'image/png'),
  ]);
}

function encodeChatMessage({
  source,
  text = '',
  images = [],
  imageTag = 0,
  toolCallId = '',
  reasoning = '',
  reasoningTagNum = 0,
  signature = '',
  env = process.env,
}) {
  const fields = [
    writeStringField(1, randomUUID()),
    writeVarintField(2, source),
    writeStringField(3, text),
  ];
  if (toolCallId) fields.push(writeStringField(7, toolCallId));
  if (imageTag) {
    for (const image of images) {
      fields.push(writeMessageField(imageTag, encodeImageData(image, env)));
    }
  }
  if (reasoning && reasoningTagNum) {
    fields.push(writeStringField(reasoningTagNum, reasoning));
  }
  // #12 thinking signature, only beside its #11 thinking (signed replay).
  if (signature && reasoning && reasoningTagNum === 11) fields.push(writeStringField(12, signature));
  return Buffer.concat(fields);
}

// Encode a role=2 assistant ChatMessage carrying one native #6 tool_call.
// VERIFIED-FROM-WIRE (req022 CM#4 #6): the request-side ToolCall is a PROTOBUF
// submessage { #1 id, #2 name, #3 argsJSON } — only #6.3 (args) is a JSON string.
// This CONTRADICTS the static-RE memory note "request-side ToolCall is
// serde-JSON": the ENVELOPE is protobuf, only the args VALUE is JSON. Wire wins.
// Symmetric with the response-side ChatToolCall #6{1:id,2:name,3:arguments}
// pinned in parseToolCallTagMap.
function encodeAssistantToolCall({ id, name, argsJson, reasoning, reasoningTagNum = 11, signature = '' }) {
  const toolCall = Buffer.concat([
    writeStringField(1, id),
    writeStringField(2, name),
    writeStringField(3, argsJson),
  ]);
  const parts = [
    writeStringField(1, randomUUID()),      // #1 per-message uuid
    writeVarintField(2, SOURCE.ASSISTANT),  // #2 role=2
    writeMessageField(6, toolCall),         // #6 tool_call submessage
  ];
  // The req022 note records #11 on its role=2 frames (59B-1470B); it is not a
  // requirement to emit reasoning when replay is disabled. Nor does that note
  // establish equal payloads across frames split from one logical turn.
  // Enabled replay copies the chosen value to preText and every native call
  // frame (#11 for '1', #9 for '9'). Deduplicating without a paired capture
  // would replace one unverified replay policy with another.
  // The tag-number guard is load-bearing even though callers currently pass reasoningText
  // only when reasoningTagNum is truthy: field 0 is RESERVED in protobuf and a zero tag
  // would serialize makeTag(0,2) — an invalid frame — so the encoder refuses it itself.
  if (reasoning && reasoningTagNum) parts.push(writeStringField(reasoningTagNum, reasoning));
  // #12 thinking signature, only beside its #11 thinking (signed replay).
  if (signature && reasoning && reasoningTagNum === 11) parts.push(writeStringField(12, signature));
  return Buffer.concat(parts);
}

// Native tool definitions — GROUNDWORK, gated behind DEVIN_CONNECT_TOOL_DEF_TAGS.
//
// The calibrated proto (get-chat-message-CALIBRATED.proto) VERIFIES the repeated
// `tools` field lives at GetChatMessageRequest #10 (req-6 carried ~24 ToolDefs).
// What is NOT calibrated is the SUBFIELD layout inside each ToolDef — the recon
// only saw "3 fields each (name/desc/schema-ish)" without confirmed tag numbers,
// because the prost binary embeds no descriptor and the capture didn't decode the
// inner message. So native tool defs are OFF by default: WindsurfAPI keeps folding
// tools into the prompt (tool-emulation), which works on every model.
//
// The OUTER tag is VERIFIED-FROM-BINARY: GetChatMessageRequest.tools lives at
// #10 (CALIBRATED.proto, a live capture carried ~24 ToolDefs there).
//
// The INNER tags are now VERIFIED-FROM-FRAME too (2026-07-04 paid probe, teams
// account, claude-opus-4-8): sending a native ToolDef at #10 with inner tags
// name=1/description=2/parameters=3 and NO prompt preamble, the upstream model
// correctly understood the tools and emitted native tool_calls (grep_repo with
// the right args). Two independent fires (def-only, then def+call closed loop)
// both confirmed. So `10,1,2,3` is the calibrated, working layout — no longer a
// guess. It STILL stays behind the env gate (default OFF → prompt emulation)
// because (a) the response decode gate (DEVIN_CONNECT_TOOL_CALL_TAGS) must be on
// in tandem for the native path to be useful, and (b) enabling by default is a
// behavior change we roll out deliberately, not implicitly.
//
// Accepted forms of DEVIN_CONNECT_TOOL_DEF_TAGS (fail-closed to null on any
// malformed value — never emit a broken frame):
//   - key=val:   "name=1,description=2,parameters=3[,strict=12][,outer=10]"
//     (strict is #12 on the current server; #4 is reserved there)
//   - positional (back-compat): STRICT 4-tuple "10,1,2,3" = outer,name,description,schema.
// To omit outer (use the #10 default), use the key=val form — a positional
// 3-tuple is ambiguous and fails closed to null.
// name/description/parameters are MANDATORY; missing any → null.
// custom_tool / defer_loading are Devin-proprietary with no OpenAI source and
// are deliberately NOT accepted (emitting them risks a broken frame).
const TOOL_DEF_INT_MAX = 536870912; // 2^29, protobuf field-number ceiling
function validTag(n) { return Number.isInteger(n) && n > 0 && n < TOOL_DEF_INT_MAX; }
// VERIFIED tool-def inner tags (paid frame-confirmed 2026-07-04, opus-4-8 on
// teams: outer #10, name=1, description=2, parameters=3). Used as the fallback
// when the nativeToolCall flag is on but no explicit env override is set.
export const DEFAULT_DEF_TAGS = Object.freeze({ outer: 10, name: 1, description: 2, parameters: 3, schema: 3 });

export function getToolDefTags(env = process.env, { useDefault = false } = {}) {
  const raw = String(env.DEVIN_CONNECT_TOOL_DEF_TAGS || '').trim();
  if (!raw) return useDefault ? { ...DEFAULT_DEF_TAGS } : null; // unset → default (flag on) or disabled

  const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
  const map = {};
  if (parts.some((p) => p.includes('='))) {
    // key=val form
    for (const p of parts) {
      const [k, v] = p.split('=').map((s) => s.trim());
      const n = Number.parseInt(v, 10);
      // Only accept keys we know how to emit; silently ignore proprietary ones
      // so a stray custom_tool= can't break the whole gate.
      if (['outer', 'name', 'description', 'parameters', 'schema', 'strict'].includes(k) && validTag(n)) {
        map[k === 'schema' ? 'parameters' : k] = n;
      }
    }
  } else {
    // positional form is back-compat ONLY and STRICT: exactly 4 numbers =
    // outer,name,description,schema(=parameters). A 3- or 5-tuple is ambiguous
    // (is "10,1,2" outer,name,desc-missing-params, or name,desc,params?) so we
    // fail closed to null — use the explicit key=val form to omit outer.
    const nums = parts.map((s) => Number.parseInt(s, 10));
    if (nums.length !== 4 || nums.some((n) => !validTag(n))) return null;
    [map.outer, map.name, map.description, map.parameters] = nums;
  }

  if (map.outer === undefined) map.outer = 10; // VFB default
  // Mandatory inner tags; without all three there's nothing coherent to emit.
  if (!validTag(map.outer) || !validTag(map.name) || !validTag(map.description) || !validTag(map.parameters)) {
    return null; // fail closed to emulation
  }
  // `schema` kept as an alias of `parameters` for back-compat with existing
  // callers/tests that read tags.schema.
  const out = { outer: map.outer, name: map.name, description: map.description, parameters: map.parameters, schema: map.parameters };
  if (validTag(map.strict)) out.strict = map.strict;
  return out;
}

/**
 * Encode one ToolDef sub-message from an OpenAI function-tool entry
 * ({ type:'function', function:{ name, description, parameters, strict } }).
 *
 * parameters (the JSON Schema) is serialized to a STRING. Rationale: static RE
 * ruled out a nested google.protobuf.Struct (Struct/Value type count in the
 * binary is 0), and schemars→JSON + serde_json RawValue evidence points to a
 * serialized-JSON field. string vs bytes share wiretype 2 and are byte-identical
 * on the wire, so if a paid capture ever proves bytes, the ONLY change is
 * swapping writeStringField→writeBytesField here (zero risk). ← SWITCH POINT.
 *
 * strict (OpenAI function.strict) is emitted as a varint bool ONLY when the
 * operator calibrated a `strict` tag AND the tool asked for it — calibrate
 * `strict=12` (the field's number on the current server; #4 is reserved there).
 * The default tag map has no strict key, so nothing is emitted unless an
 * operator opts in. custom_tool / defer_loading are Devin-proprietary and
 * never emitted (getToolDefTags rejects their keys).
 */
// Normalize an OpenAI/MCP function-parameters JSON Schema into the minimal,
// well-formed shape upstream accepts. Real clients (OpenCode, MCP servers) emit
// schemas with missing/loose fields or proprietary keys ($schema, unevaluated
// keywords) that a strict upstream can reject (→ `invalid_argument` → 400
// REQUEST_REJECTED). Portable
// hardening (kiro's normalize_json_schema, repo-hank9999): guarantee an object
// schema with a `properties` object and a string[] `required`, and DROP keys the
// upstream doesn't want ($schema). Non-destructive to the caller's real schema
// content — only fixes the envelope. Returns a plain object safe to JSON.stringify.
export function normalizeToolSchema(schema) {
  // Non-object / missing schema → the canonical empty object schema.
  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
    return { type: 'object', properties: {} };
  }
  const out = { ...schema };
  // $schema is a meta key some clients add; upstream schemas never carry it.
  delete out.$schema;
  // Strip top-level oneOf/anyOf/allOf BEFORE the properties/type normalization
  // below — a schema that is a bare top-level combinator (no properties) would
  // otherwise be forced to `{type:'object',properties:{}}` and lose its real
  // parameters. Recovering properties from a variant here lets the properties
  // guard below see the recovered object instead of overwriting it with {}.
  if (stripTopLevelCombinators(out)) recordSchemaNormalized();
  // Force an object schema (function parameters are always an object).
  if (out.type !== 'object') out.type = 'object';
  // properties must be an object (not array/null/absent).
  if (!out.properties || typeof out.properties !== 'object' || Array.isArray(out.properties)) {
    out.properties = {};
  }
  // required must be a string[] naming only real properties; drop anything else.
  if (out.required !== undefined) {
    if (!Array.isArray(out.required)) {
      delete out.required;
    } else {
      const keys = new Set(Object.keys(out.properties));
      const req = out.required.filter((r) => typeof r === 'string' && keys.has(r));
      if (req.length) out.required = req; else delete out.required;
    }
  }
  return normalizeSchemaTree(out);
}

// ★ Strip top-level oneOf/anyOf/allOf combinators in place (ported from kiro.rs
// converter.rs strip_top_level_combinators). Bedrock/Anthropic reject a tool
// parameters schema whose ROOT is a combinator instead of an object; a bare
// top-level oneOf also collides with our "force object" normalization. We remove
// the three combinator keys and, only when the schema had NO top-level
// properties of its own, recover properties/required/additionalProperties/
// description from the first `type:object` variant (never overwriting keys the
// schema already has — matches kiro's or_insert semantics). Byte-identical for
// any schema without a top-level combinator: the loop's `Array.isArray` guard
// means a schema lacking these keys is left completely untouched. Mutates `out`.
function stripTopLevelCombinators(out) {
  const hasPropsInitially = Object.prototype.hasOwnProperty.call(out, 'properties');
  let recovered = false;
  let stripped = false;
  for (const key of ['oneOf', 'anyOf', 'allOf']) {
    if (!Array.isArray(out[key])) continue;
    const variants = out[key];
    delete out[key];
    stripped = true;
    // Recover real parameters only if the root had none and we haven't already
    // pulled them from an earlier combinator.
    if (hasPropsInitially || recovered) continue;
    const objVariant = variants.find(
      (v) => v && typeof v === 'object' && !Array.isArray(v) && v.type === 'object',
    );
    if (!objVariant) continue;
    for (const field of ['properties', 'required', 'additionalProperties', 'description']) {
      if (Object.prototype.hasOwnProperty.call(objVariant, field)
          && !Object.prototype.hasOwnProperty.call(out, field)) {
        out[field] = objVariant[field];
      }
    }
    recovered = true;
  }
  return stripped;
}

const LITERAL_VALUE_KEYS = new Set(['default', 'examples', 'enum', 'const']);
// ★ Recursively normalize a JSON Schema tree for the native ToolDef path.
// Two jobs, both required by upstream's decoder:
//  1. Strip `description` ANNOTATIONS, but preserve properties that happen to
//     be NAMED "description" (a real parameter, e.g. Cursor's Task tool has a
//     `description` property). Only schema-annotation `description` keys
//     (siblings of `type`/`properties`/`required`) are removed. This
//     neutralizes upstream's MCP-gate fingerprinting on parameter descriptions
//     while keeping the full schema structure (types, required, enums, nested
//     objects) intact for the model.
//  2. Lower every `type` ARRAY to an equivalent `anyOf` of single-type members.
//     Upstream rejects the whole request with `invalid_argument` on
//     `"type": ["number","null"]` (reproduced live with omp grep's `skip`
//     parameter), while `anyOf: [{type:"number"},{type:"null"}]` is accepted.
//     The lowering is lossless: `anyOf` with one `{type: member}` per member
//     accepts exactly the same values, nullability included.
// The traversal is the existing one: every object/array is rebuilt (so the
// caller's schema is never mutated). `inProperties` keeps its original meaning
// (the current object is a `properties` MAP, whose keys are parameter names) so
// description stripping stays byte-identical. `kind` is the accurate container
// check used only for type lowering:
//   'schema'     — a JSON Schema object (the only place a `type` keyword lives)
//   'properties' — a properties MAP: its KEYS are parameter names, its VALUES
//                  are schemas (so a parameter named "properties"/"default"
//                  still gets lowered)
//   'literal'    — caller data from default/examples/enum/const. STICKY: every
//                  descendant, including through arrays and nested objects, is
//                  data, never a schema.
function normalizeSchemaTree(value, inProperties = false, kind = 'schema') {
  if (Array.isArray(value)) {
    // Tuple items/prefixItems/combinator arrays contain schemas; literal arrays
    // (enum/examples values) stay literal all the way down.
    const itemKind = kind === 'literal' ? 'literal' : 'schema';
    return value.map((item) => normalizeSchemaTree(item, false, itemKind));
  }
  if (!value || typeof value !== 'object') return value;
  const out = {};
  // Only a schema object carries the `type` keyword; inside a `properties` map
  // `type` is just a parameter name. A malformed (non-string-member) or empty
  // array is left untouched rather than rejected.
  const typeMembers = kind === 'schema'
    && Array.isArray(value.type)
    && value.type.length > 0
    && value.type.every((member) => typeof member === 'string')
    ? value.type
    : null;
  for (const [key, child] of Object.entries(value)) {
    if (key === 'description' && !inProperties) continue;
    if (key === 'type' && typeMembers) continue;
    // A schema's `properties` value is a map; a schema's literal-value keys are
    // caller data. Inside a properties map every value is a schema; inside a
    // literal everything stays literal.
    const childKind = kind === 'literal'
      ? 'literal'
      : kind === 'properties'
        ? 'schema'
        : key === 'properties'
          ? 'properties'
          : LITERAL_VALUE_KEYS.has(key) ? 'literal' : 'schema';
    out[key] = normalizeSchemaTree(child, key === 'properties', childKind);
  }
  if (typeMembers) {
    const lowered = typeMembers.map((member) => ({ type: member }));
    // `anyOf` is the only combinator key the lowering would collide with. Keep
    // an existing one and AND the lowered union in through `allOf` (created, or
    // appended when it already exists) so neither semantic is dropped:
    //   anyOf(A...) AND type∈T  ≡  anyOf(A...) AND allOf(anyOf(T...))
    // (a sibling allOf is the intersection, so no branch cross-product is
    // needed). oneOf/allOf are independent keywords and simply coexist.
    if (out.anyOf === undefined) {
      out.anyOf = lowered;
    } else if (Array.isArray(out.allOf)) {
      out.allOf = [...out.allOf, { anyOf: lowered }];
    } else if (out.allOf === undefined) {
      out.allOf = [{ anyOf: lowered }];
    } else {
      out.type = [...value.type]; // malformed anyOf + allOf: preserve the original
    }
  }
  return out;
}

// ★ Tool-description length cap (2026-07-10, verified from live paid probes).
// SUPERSEDED (2026-07-13): the MCP-gate fix below (encodeToolDef replaces the
// top-level description with the tool name and normalizeSchemaTree removes
// parameter-schema `description` annotations) is the authoritative approach —
// it neutralizes BOTH the description-length threshold AND the MCP-gate
// fingerprint match in one pass. capToolDescription is no longer called by
// encodeToolDef. The function + env knob are retained as an escape hatch in
// case a future upstream change reintroduces a pure length threshold without
// the fingerprint match; set WINDSURFAPI_TOOL_DESC_MAX to re-enable truncation
// if you wire it back into encodeToolDef. Default 500 (proven-safe for the
// length-only threshold); 0 = no cap.
function toolDescMax(env = process.env) {
  const raw = Number(env.WINDSURFAPI_TOOL_DESC_MAX);
  return Number.isFinite(raw) && raw >= 0 ? raw : 500;
}
function capToolDescription(desc, env = process.env) {
  const cap = toolDescMax(env);
  const s = String(desc);
  if (cap <= 0 || s.length <= cap) return s;
  return s.slice(0, cap);
}

// ★ MCP-gate neutralization (2026-07-13, verified from live A/B probes).
// Upstream server.codeium.com pattern-matches tool descriptions (both the
// top-level ToolDef #10.description AND parameter schema `description` keys)
// for known tool signatures and rejects the whole request with
// permission_denied: "Unable to process request due to an MCP configuration
// issue." Live A/B on Cursor's real 21-tool inventory found 8 of 21 tools
// triggered the gate (AskQuestion, Glob, Grep, Read, ReadLints, Shell,
// WebFetch, WebSearch). The gate fires on a COMBINATION of top-level
// description + parameter descriptions — removing either alone is
// insufficient for most tools.
//
// Fix: replace the top-level description with the tool name (the model
// identifies tools by name, not by description prose) and strip all
// `description` annotations from the parameter JSON Schema (see
// normalizeSchemaTree in normalizeToolSchema). Schema structure (types,
// required, enums, nested objects, properties named "description") is
// preserved — only human-readable annotation text is removed.
//
// ONLY needed on the native tool-def path (DEVIN_CONNECT_TOOL_DEF_TAGS on).
// With native OFF (prompt emulation), descriptions ride in prompt text which
// upstream does not scan — no gate, no neutralization needed.

function encodeToolDef(tool, tags) {
  const fn = tool?.function || {};
  const fields = [];
  if (fn.name) fields.push(writeStringField(tags.name, String(fn.name)));
  if (fn.description) fields.push(writeStringField(tags.description, String(fn.name || 'tool')));
  if (fn.parameters !== undefined) {
    // SWITCH POINT (see header): string today; writeBytesField if RE/capture proves bytes.
    // Normalize the schema envelope first so a malformed client/MCP schema can't
    // corrupt the ToolDef and trip a strict upstream decode.
    fields.push(writeStringField(tags.parameters, JSON.stringify(normalizeToolSchema(fn.parameters))));
  }
  if (tags.strict && fn.strict === true) {
    fields.push(writeVarintField(tags.strict, 1));
  }
  return Buffer.concat(fields);
}

// ─── Short-lived user JWT (GetUserJwt), opt-in ──────────────────────────────
//
// The upstream exposes exa.auth_pb.AuthService/GetUserJwt, which exchanges the
// long-lived session token for a short-lived HS256 JWT (~24 min) that rides the
// chat request as ClientMetadata #21. Four independent .proto reimplementations
// agree on the method name and the response shape, and one third-party client
// treats the JWT as REQUIRED for chat RPCs.
//
// It ships default-OFF (DEVIN_CONNECT_USER_JWT=1 to enable), for one reason: our
// chat path demonstrably works WITHOUT it today. "Another client sends this" is a
// coordinate, not evidence that our requests need it, and adding an unrequested
// credential field to a working wire is the kind of change that fails in a way
// only production traffic reveals. Enabling it is therefore an operator decision
// backed by their own capture. When off, not a single byte of the request changes
// and no extra RPC is issued.
//
// Cache design, mirroring the one third-party client that ships this: keyed per
// (token, host), refreshed 60s before expiry, with in-flight coalescing so a burst
// of concurrent requests mints once — plus a monotonic epoch that invalidates a
// mint already in flight. The epoch is the part worth copying deliberately: without
// it, a mint started before a logout/rotation can land afterwards and repopulate
// the cache with a credential belonging to the PREVIOUS account, which then rides
// the next tenant's request. The failure is silent and cross-account, so the guard
// is not optional.
const _userJwtCache = new Map(); // `${token}\0${host}` → { jwt, expMs, epoch }
const _userJwtInflight = new Map(); // same key → Promise<string|null>
let _userJwtEpoch = 0;

/** Bump the epoch so any in-flight mint is discarded instead of cached. */
export function invalidateUserJwtCache() {
  _userJwtEpoch += 1;
  _userJwtCache.clear();
  _userJwtInflight.clear();
}

export function isUserJwtEnabled(env = process.env) {
  return String(env.DEVIN_CONNECT_USER_JWT ?? '').trim() === '1';
}

/** Seconds of headroom before `exp` at which a cached JWT is considered stale. */
const USER_JWT_REFRESH_SKEW_MS = 60_000;
const USER_JWT_CACHE_MAX = 500;

/**
 * Absolute deadline for one GetUserJwt mint, ms. Overridable per call via
 * `mintUserJwt(token, { timeoutMs })`; a non-finite or non-positive override
 * falls back here.
 *
 * This RPC runs BEFORE the chat request is built (`streamChat` awaits the mint),
 * so a peer that accepts the connection and then never answers used to pin the
 * whole chat request — and, through the coalescing map above, every other
 * request sharing that token — with no timer running anywhere: the stream's own
 * absolute deadline (DEVIN_CONNECT_TIMEOUT_MS) starts later, and a cold cache
 * has nothing to serve. 15s is the ceiling for an RPC that gates a user-visible
 * request: a healthy mint is one small round trip well under a second, so a peer
 * still silent at 15s is dead and the caller is better off on the historical
 * no-JWT wire. The catalog's metadata unary probe waits 30s
 * (devin-connect-catalog.js:84) but sits in front of no user request.
 *
 * Deliberately NOT env-configurable: the failure this bound prevents is
 * unbounded, so every value a switch could offer is a worse one.
 */
export const USER_JWT_MINT_TIMEOUT_MS = 15_000;

function pruneUserJwtCache(now) {
  for (const [key, entry] of _userJwtCache) {
    if (entry.expMs - USER_JWT_REFRESH_SKEW_MS <= now) _userJwtCache.delete(key);
  }
  while (_userJwtCache.size > USER_JWT_CACHE_MAX) {
    _userJwtCache.delete(_userJwtCache.keys().next().value);
  }
}

/**
 * `exp` (ms) out of a JWT payload, or null when it carries none.
 *
 * A JWT with no readable `exp` is treated as non-cacheable rather than
 * cached-forever: holding a credential we cannot age out is how a rotated or
 * revoked token keeps getting presented until the process restarts.
 */
export function userJwtExpiryMs(jwt) {
  if (typeof jwt !== 'string') return null;
  const parts = jwt.split('.');
  if (parts.length < 2) return null;
  try {
    const json = Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
    const exp = Number(JSON.parse(json)?.exp);
    return Number.isFinite(exp) && exp > 0 ? exp * 1000 : null;
  } catch {
    // A malformed payload is "no expiry known", never a throw: this runs on the
    // request path and an upstream that returns junk must degrade, not 500.
    return null;
  }
}

/**
 * Mint (or reuse) the short-lived user JWT for one session token.
 *
 * Returns null on ANY failure — unreachable upstream, non-200, unparseable frame,
 * missing field. Null is a first-class outcome, not an error: the chat request is
 * known to work without #21, so a mint failure must degrade to the historical wire
 * rather than fail the user's request. That is also why nothing here throws.
 *
 * @param {string} sessionToken
 * @param {{host?: string, signal?: AbortSignal, now?: number, timeoutMs?: number}} [opts]
 *   `timeoutMs` overrides USER_JWT_MINT_TIMEOUT_MS for this mint (non-finite or
 *   non-positive → that default, never "no deadline").
 * @returns {Promise<string|null>}
 */
export async function mintUserJwt(sessionToken, opts = {}) {
  if (!sessionToken) return null;
  const host = opts.host || HOST;
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const key = `${sessionToken} ${host}`;

  pruneUserJwtCache(now);
  const hit = _userJwtCache.get(key);
  if (hit && hit.expMs - USER_JWT_REFRESH_SKEW_MS > now) {
    _userJwtCache.delete(key);
    _userJwtCache.set(key, hit);
    return hit.jwt;
  }

  // Coalesce: a burst of concurrent requests on a cold cache must mint once.
  const pending = _userJwtInflight.get(key);
  if (pending) return pending;

  // Captured BEFORE the await. If invalidateUserJwtCache() runs while this mint is
  // in flight, the epoch moves and the result is dropped instead of repopulating
  // the cache with a credential for an account that is no longer current.
  const epoch = _userJwtEpoch;
  const task = (async () => {
    try {
      const proto = writeMessageField(1, buildClientMetadata(sessionToken, opts.deviceSeed));
      const framed = wrapEnvelope(proto, { compress: false });
      const raw = await postConnectUnary(host, USER_JWT_PATH, framed, sessionToken, opts.signal, opts.timeoutMs);
      if (!raw) return null;
      const jwt = getField(parseFields(raw), 1, 2)?.value?.toString('utf8')?.trim();
      if (!jwt) return null;
      if (epoch !== _userJwtEpoch) return null; // account changed under us
      const expMs = userJwtExpiryMs(jwt);
      // No readable exp → usable once, never cached. See userJwtExpiryMs.
      const cacheNow = Number.isFinite(opts.now) ? opts.now : Date.now();
      if (expMs && expMs - USER_JWT_REFRESH_SKEW_MS > cacheNow) {
        _userJwtCache.set(key, { jwt, expMs, epoch });
        pruneUserJwtCache(cacheNow);
      }
      return jwt;
    } catch (e) {
      log.debug(`GetUserJwt mint failed (degrading to the no-JWT wire): ${e.message}`);
      return null;
    }
  })();
  // An old-epoch mint must not delete a newer epoch's pending task. Attach
  // cleanup after creation so synchronous encoding failures are cleaned too.
  const pendingTask = task.finally(() => {
    if (_userJwtInflight.get(key) === pendingTask) _userJwtInflight.delete(key);
  });
  _userJwtInflight.set(key, pendingTask);
  return pendingTask;
}

/**
 * One unary Connect-RPC round trip → the response's single protobuf frame.
 *
 * Resolves null (never rejects) for any non-200, malformed body, transport
 * failure, or `timeoutMs` expiry: every caller here treats failure as "carry on
 * without the optional credential".
 */
function postConnectUnary(host, path, framed, sessionToken, signal, timeoutMs) {
  // The one place the deadline is resolved, so a bad override can neither
  // disable the bound nor arm an instant timer — a 0 here would fire on the
  // next turn of the loop, kill a healthy mint and silently turn the opt-in
  // feature into an always-null no-op.
  const deadlineMs = Number.isFinite(timeoutMs) && timeoutMs > 0
    ? timeoutMs : USER_JWT_MINT_TIMEOUT_MS;
  return new Promise((resolve) => {
    let settled = false;
    let timer = null;
    const done = (v) => {
      if (settled) return;
      settled = true;
      if (timer) { clearTimeout(timer); timer = null; }
      resolve(v);
    };
    let req;
    try {
      req = requestImpl({
        hostname: host,
        port: 443,
        path,
        method: 'POST',
        headers: connectHeaders({
          authorization: `Basic ${sessionToken}-${sessionToken}`,
          'Content-Length': framed.length,
          Accept: '*/*',
        }),
        signal,
      }, (res) => {
        // Bounded read even though a non-200 body is discarded below: the
        // response is attacker-controlled and reading it unbounded is the same
        // availability bug the streamChat non-200 branch is guarded against
        // (see createBoundedBodyAccumulator). The same ceiling also covers a
        // 200 body — a unary frame (GetUserJwt) is small, so a body past the
        // ceiling is malformed either way and is treated as such rather than
        // parsed from a truncated prefix.
        const buffer = createBoundedBodyAccumulator();
        res.on('data', buffer.onData);
        res.on('end', () => {
          const { raw: body, truncated } = buffer.finish();
          if (truncated) {
            log.warn(`[devin-connect] ${path} response body exceeded ${MAX_ERROR_BODY_BYTES} bytes; truncated`);
            return done(null);
          }
          if (res.statusCode !== 200) return done(null);
          // Connect envelope: [flags][len:4BE][payload]. A trailer-only response
          // (flag 0x02) carries no message and must not be parsed as one.
          if (body.length < 5) return done(null);
          const flags = body[0];
          const len = body.readUInt32BE(1);
          if (flags & 0x02) return done(null);
          // subarray CLAMPS an over-long declared length; refuse explicitly
          // like the streaming path does instead of feeding a truncated
          // payload to the decoder (S4-D8).
          if (5 + len > body.length) return done(null);
          const payload = body.subarray(5, 5 + len);
          done((flags & 0x01) ? tryGunzip(payload) : payload);
        });
        res.on('error', () => done(null));
      });
      req.on('error', () => done(null));
      // Absolute deadline from request start — not a socket idle timeout, which
      // any byte of traffic resets and which does not cover the connect phase.
      // Armed BEFORE end() so the write can never run ahead of the bound; on
      // expiry the socket is destroyed and the promise leaves through the same
      // resolve(null) door as every other failure above.
      timer = setTimeout(() => {
        try { req.destroy(); } catch { /* already closed */ }
        done(null);
      }, deadlineMs);
      req.end(framed);
    } catch {
      done(null);
    }
  });
}

/**
 * Build the ClientMetadata sub-message (field #1). The token is embedded SINGLE
 * here (the doubling is only for the HTTP Authorization header).
 *
 * `userJwt` (optional) is appended as #21. Absent → byte-identical to before, so
 * the default path is untouched.
 */
function buildClientMetadata(token, deviceSeed, userJwt) {
  return Buffer.concat([
    writeStringField(1, CLIENT_NAME),
    writeStringField(2, CLIENT_VERSION),
    writeStringField(3, token),
    writeStringField(4, 'en'),
    writeStringField(5, 'windows'),
    writeStringField(7, CLIENT_VERSION),
    writeStringField(12, CLIENT_NAME),
    // #21 short-lived user JWT, only when the caller minted one (opt-in). Emitted
    // before #31 to keep the fields ascending, matching every other field here.
    // An empty/absent value contributes NOTHING — not a zero-length #21 — so the
    // default request stays byte-identical to the pre-JWT wire.
    ...(userJwt ? [writeStringField(METADATA_USER_JWT_FIELD, userJwt)] : []),
    // #31 device fingerprint. deviceSeed (per-account stable mode, opt-in) makes
    // it deterministic; undefined → historical per-request random (byte-equiv).
    writeStringField(31, generateFingerprint(deviceSeed)),
  ]);
}

/** Build CompletionConfig (field #8).
 *
 * TAG MAP RE-CALIBRATED FROM THE SCHEMA (was: guessed from field NAME ORDER).
 * Four independent `.proto` definitions of CompletionConfiguration agree:
 *   #2 = max_tokens, #3 = max_newlines.
 * The earlier layout had those two swapped — it sent the 128000 context window as
 * max_tokens and the caller's output cap as max_newlines. That is why the
 * free-tier probe (2026-06-30) saw `maxTokens` 16 → 1000 produce IDENTICAL output
 * (256 completion tokens, finish=stop): the cap never reached the field that
 * enforces it, and max_tokens was pinned wide open at the context window. The old
 * comment read that as "the free model ignores the cap" and declined to re-tag.
 *
 * The swapped wire still produced correct completions (both fields are plausible
 * varints, and a large max_newlines is a no-op), so "a working capture" was never
 * evidence for the mapping — only the schema is.
 */
// ─── tool_choice / parallel_tool_calls passthrough (#12 / #11), opt-in ──────
//
// OpenAI clients send tool_choice ('auto' | 'none' | 'required' | 'any' |
// {type:'function', function:{name}}) and parallel_tool_calls. Today the repo can
// only CLASSIFY tool_choice for cache-keying and prompt-emulation decisions
// (handlers/chat.js:347) — it never reaches the upstream, so 'required' cannot be
// honoured natively and a forced tool name is a request we silently downgrade.
//
// Third-party .proto reimplementations report:
//   #12 tool_choice → ChatToolChoice { option_name = 1, tool_name = 2 }
//   #11 disable_parallel_tool_calls (bool)
//
// UNCONFIRMED COORDINATES. These are DECLARATION ORDER in someone else's .proto,
// not a wire capture, and prost allows tag gaps — so declaration order is not a tag
// number. This is the same epistemic position as the billing tags and #13 above, and
// it gets the same treatment: default-OFF, and the comment says so rather than
// implying the mapping is known. The option_name STRINGS are a second unknown: the
// upstream vocabulary is not observed either, so the map is operator-overridable.
//
// Enable with DEVIN_CONNECT_TOOL_CHOICE=1. Off → neither field is emitted and the
// request is unchanged. An operator who has a capture can also re-point the tags via
// DEVIN_CONNECT_TOOL_CHOICE_TAGS="choice=12,parallel=11".
const TOOL_CHOICE_DEFAULT_TAGS = Object.freeze({ choice: 12, parallel: 11 });

export function isToolChoicePassthroughEnabled(env = process.env) {
  return String(env.DEVIN_CONNECT_TOOL_CHOICE ?? '').trim() === '1';
}

/**
 * True when the caller asked for sequential tool calls (`parallel_tool_calls:false`)
 * and this build will serve the request WITHOUT honouring that hint.
 *
 * The hint can only be honoured natively, through #11 `disable_parallel_tool_calls`,
 * which is opt-in because its tag is unconfirmed (see the header above). With the
 * switch off the field is not emitted, so the request goes upstream unchanged and
 * the model is free to return N calls in one turn — the live matrix measured 4/4
 * models doing exactly that with the flag set false (evidence/ToolMatrixLive.md §8).
 *
 * This is a PREFERENCE, not a correctness requirement, so the handler serves the
 * request, logs the drop once per process and counts it
 * (`parallel_tool_calls_hint_ignored`). It used to answer 400 `unsupported_parameter`
 * instead, which made Codex CLI 0.164 — it sends the hint on every request — fail on
 * its first turn under the shipped default (evidence/ClientSandboxes.md §3.3 C1).
 *
 * Serialising at the proxy is NOT an alternative either: the upstream decides the
 * call count inside one turn, so emitting only the first of N would discard work the
 * model already performed and the client would never learn the rest existed.
 */
export function isParallelToolCallsHintIgnored(parallelToolCalls, env = process.env) {
  return parallelToolCalls === false && !isToolChoicePassthroughEnabled(env);
}

// Top-level tags GetChatMessageRequest already occupies. An override that lands on
// one of these does not "win" — protobuf permits a repeated appearance of a
// non-repeated field and decoders take the LAST, so pointing `choice` at #1 emits a
// second #1 next to the 800-byte ClientMetadata and lets a 10-byte tool_choice
// overwrite the metadata the upstream authenticates on. Silent, and it looks like an
// auth failure rather than a config typo. Measured: with choice=1 the request carried
// TWO #1 fields (814 and 10 bytes).
//
// #11/#12/#13 are absent from this set on purpose — they are the (unconfirmed)
// coordinates this feature and the prompt-cache switch are here to occupy.
const REQUEST_OCCUPIED_TAGS = Object.freeze(new Set([1, 2, 3, 7, 8, 15, 16, 20, 21, 22]));

/**
 * Operator-overridable tag map. Invalid entries are skipped, never fatal.
 *
 * "Invalid" includes an override that collides with a field the request already
 * emits: keeping the default is strictly better than corrupting the message, and the
 * skip is logged so the typo is discoverable instead of silent.
 */
export function getToolChoiceTags(env = process.env) {
  const raw = String(env.DEVIN_CONNECT_TOOL_CHOICE_TAGS ?? '').trim();
  if (!raw) return TOOL_CHOICE_DEFAULT_TAGS;
  const out = { ...TOOL_CHOICE_DEFAULT_TAGS };
  for (const pair of raw.split(',')) {
    const [k, v] = pair.split('=').map((s) => s.trim());
    const n = Number.parseInt(v, 10);
    if (k !== 'choice' && k !== 'parallel') continue;
    if (!Number.isInteger(n) || n <= 0) continue;
    if (REQUEST_OCCUPIED_TAGS.has(n)) {
      log.warn(`DEVIN_CONNECT_TOOL_CHOICE_TAGS: ${k}=${n} collides with an existing request field; keeping default ${out[k]}`);
      continue;
    }
    // Both keys pointing at one tag is the same collision, one step removed.
    if (k === 'parallel' && n === out.choice) {
      log.warn(`DEVIN_CONNECT_TOOL_CHOICE_TAGS: parallel=${n} collides with choice; keeping default ${out.parallel}`);
      continue;
    }
    out[k] = n;
  }
  if (out.choice === out.parallel) {
    log.warn(`DEVIN_CONNECT_TOOL_CHOICE_TAGS: choice and parallel both ${out.choice}; reverting both to defaults`);
    return TOOL_CHOICE_DEFAULT_TAGS;
  }
  return Object.freeze(out);
}

/**
 * Normalize an OpenAI tool_choice into {optionName, toolName}, or null.
 *
 * Returns null for 'auto' as well as for absent input: 'auto' IS the upstream
 * default, so emitting it would add a field that changes nothing while widening the
 * unconfirmed surface. Only a non-default intent is worth sending.
 */
export function normalizeToolChoice(toolChoice) {
  if (toolChoice == null) return null;
  if (typeof toolChoice === 'string') {
    const v = toolChoice.trim().toLowerCase();
    if (v === 'none') return { optionName: 'none', toolName: null };
    // 'any' is Anthropic's spelling of OpenAI's 'required'; both mean "call ≥1".
    if (v === 'required' || v === 'any') return { optionName: 'required', toolName: null };
    return null; // 'auto' or anything unrecognized
  }
  const name = toolChoice?.function?.name ?? toolChoice?.name;
  if (typeof name === 'string' && name.trim()) {
    return { optionName: 'function', toolName: name.trim() };
  }
  return null;
}

/**
 * ChatToolChoice sub-message (#12 payload), or null when there is nothing to say.
 *
 * @param {string|object} toolChoice  the caller's OpenAI-shaped tool_choice
 * @param {object} [env]
 * @returns {Buffer|null}
 */
export function buildToolChoice(toolChoice, env = process.env) {
  if (!isToolChoicePassthroughEnabled(env)) return null;
  const norm = normalizeToolChoice(toolChoice);
  if (!norm) return null;
  // ChatToolChoice is `oneof choice { option_name = 1; tool_name = 2; }`
  // (exa.chat_pb). Emitting BOTH members is a schema violation — a conformant
  // decoder keeps only the last one, so a forced tool name and the "function"
  // option overwrote each other. Write exactly one: the name when the caller
  // forced a tool, otherwise the option keyword.
  const parts = norm.toolName
    ? [writeStringField(2, norm.toolName)]
    : [writeStringField(1, norm.optionName)];
  return Buffer.concat(parts);
}

// ─── Explicit prompt caching (#13) ──────────────────────────────────────────
//
// WHAT IS MEASURED (repo history, paid Teams account):
//   - A cache HIT costs ~17.8% of a miss (#220). The upside is real and large.
//   - Caching is per-ACCOUNT and matches on the prompt PREFIX, NOT on the session
//     id. Three independent measurements say so: the #220 read A/B (bc0fd13) and
//     the write A/B (2d1a6aa) both scored hits while this file emitted a FRESH
//     randomUUID as session id on every request (session continuity did not exist
//     on the connect path until b0f8330, a day later), and chat.js:2004 records the
//     three-account experiment — identical body on a second account is a WRITE, a
//     repeat on the first is a READ.
//   - So Claude-family selectors already get IMPLICIT caching, and the
//     sticky-session subsystem pins a caller to one account, which is the condition
//     that makes it work.
//
// WHAT IS NOT MEASURED: that field #13 is the tag for system_prompt_cache_options,
// or that requesting EPHEMERAL improves on what we get implicitly. #13 comes from
// third-party .proto reimplementations, i.e. DECLARATION ORDER — and prost allows
// tag gaps, so declaration order is not a wire tag. Same epistemic position as the
// billing tags above: we know where to look, not what is true.
//
// WHY THE gpt-6 FAMILY DEFAULTS ON (2026-10-23): the live matrix
// (evidence/ToolMatrixLive.md §cache) sent five identical gpt-6-luna-max requests.
// Every one reported fresh prompt=3, cache_write=3984, and ZERO cache reads — a
// write on every round, never a hit — while Claude served 6884 cached tokens on
// every warm round and Gemini 2031. So the implicit-caching argument above does
// NOT hold for this family as observed. Two explanations survive the data:
// (a) the upstream only serves GPT reads when asked (this hint is the only
// cache-control field the wire exposes), or (b) the usage map's read tag (5,
// calibrated on Claude) is wrong for GPT and the reads exist but are invisible.
// Defaulting the hint ON for this family makes the next live sample settle (a):
// reads appear → hint required; reads stay 0 with writes still counted → the next
// suspect is (b), the tag map, not the hint. The field carries a single enum and
// no prompt bytes, so the experiment is bounded and reversible.
//
// Defaults: UNSET → ON for the gpt-6 family only, OFF everywhere else (historical
// wire unchanged). "1" → ON for every family. Any other non-empty value
// ("0", "false", …) → OFF, preserving the exact-"1" opt-in rule for operators.
// Confirm the effect with DEVIN_CONNECT_BILLING_TAGS=cache_read_tokens=5 (already
// calibrated) showing a higher read share.
const PROMPT_CACHE_EPHEMERAL = 1;

// Selectors the catalog exposes for this family: gpt-6-luna-*, gpt-6-sol-*,
// gpt-6-astra-*, and the 6.1 sol variants. Dotted aliases are accepted too so a
// caller that passes the raw name (not the resolved selector) still matches.
const GPT6_CACHE_DEFAULT = /^(?:gpt-6|gpt6)(?:[.-]1)?-(?:luna|sol|astra)(?:-|$)/i;

/**
 * @param {object} [env]
 * @param {string} [model] resolved model selector; decides the family default
 */
export function isPromptCacheEnabled(env = process.env, model = '') {
  const raw = String(env.DEVIN_CONNECT_PROMPT_CACHE ?? '').trim();
  if (raw) return raw === '1';
  return GPT6_CACHE_DEFAULT.test(String(model || ''));
}

/**
 * SystemPromptCacheOptions sub-message, or null when the switch is off.
 *
 * Null (not an empty buffer) is what keeps the default wire byte-identical: the
 * caller spreads nothing rather than emitting a zero-length #13.
 *
 * @param {string} systemPrompt  the prompt the cache would key on
 * @param {object} [env]
 * @param {string} [model] resolved model selector; decides the family default
 * @returns {Buffer|null}
 */
export function buildSystemPromptCacheOptions(systemPrompt, env = process.env, model = '') {
  if (!isPromptCacheEnabled(env, model)) return null;
  // Nothing to cache: an absent or trivially short system prompt cannot pay for a
  // cache write. Upstream bills a write at roughly an order of magnitude more than
  // a read (chat.js:2004), so asking to cache a 20-byte prompt is a pure loss.
  // The floor is deliberately low — it excludes "no system prompt" and obvious
  // noise without second-guessing an operator who has a real prefix.
  if (typeof systemPrompt !== 'string' || systemPrompt.trim().length < 64) return null;
  return writeVarintField(1, PROMPT_CACHE_EPHEMERAL);
}

function buildCompletionConfig({ maxTokens, temperature, topK, topP, maxNewlines } = {}) {
  // LIVE FINDING (free-tier swe-1-6-slow, 2026-06-30): temperature=0 reliably
  // makes the upstream return "an internal error occurred" (3/3), while 0.001
  // succeeds. OpenAI/Anthropic clients routinely send temperature=0 for
  // deterministic output, so forwarding a raw 0 would turn every such call into
  // a hard upstream failure. Clamp to a tiny epsilon floor — as close to greedy
  // as the server accepts — instead of erroring. Only applies when the caller
  // explicitly asked for sub-epsilon; the default path is untouched.
  let temp = temperature ?? DEFAULT_TEMPERATURE;
  if (temp < MIN_TEMPERATURE) temp = MIN_TEMPERATURE;
  return Buffer.concat([
    writeVarintField(1, 1),
    // Schema ground truth: #2 = max_tokens (caller's output cap), #3 = max_newlines.
    //
    // The default here is load-bearing in a way it was NOT before the tag fix. While
    // max_tokens sat on #3 (max_newlines) the value was a no-op, so DEFAULT_MAX_TOKENS
    // could be anything. Now #2 is the real cap and this default lands on the wire for
    // every caller that omits max_tokens — and two of them do: /v1/responses only sets
    // it when max_output_tokens is present (handlers/responses.js), and chat callers
    // that pass only temperature/top_p. Sending 4096 there would silently truncate
    // answers that used to complete, and — because resolveFinishReason compares
    // completion_tokens against the CALLER's cap, not the encoded one — the truncation
    // would be invisible on a defaulted call and mis-reported as 'length' on a call
    // that happened to ask for 4096.
    //
    // So the default is the repo-wide output convention (config.js MAX_TOKENS, and the
    // `|| 8192` fallbacks in handlers/messages.js and handlers/gemini.js), not the old
    // dead constant. DEFAULT_MAX_TOKENS stays 4096 for callers that read it directly.
    writeVarintField(2, maxTokens ?? WIRE_DEFAULT_MAX_TOKENS),
    // #3 = max_newlines: an explicit value wins, else the calibrated default.
    // A context window has no member in CompletionConfiguration and is never
    // written here.
    writeVarintField(3, maxNewlines ?? DEFAULT_MAX_NEWLINES),
    writeFixed64Field(5, f64le(temp)),
    writeVarintField(7, topK ?? DEFAULT_TOP_K),
    writeFixed64Field(8, f64le(topP ?? DEFAULT_TOP_P)),
  ]);
}

/**
 * Encode a GetChatMessageRequest. Field order matches the live capture exactly.
 *
 * @param {object}   params
 * @param {string}   params.token         session token (single, for proto body)
 * @param {Array}    params.messages      OpenAI-style [{role, content}]
 * @param {string}   params.model         model selector, e.g. "swe-1-6-slow"
 * @param {string}   [params.sessionId]   reuse a session id; default fresh uuid
 * @param {object}   [params.completion]  CompletionConfig overrides
 * @returns {Buffer} raw protobuf (un-enveloped)
 */
export function buildGetChatMessageRequest({ token, messages, model, sessionId, completion, tools, nativeToolCall = false, deviceSeed, userJwt, toolChoice, parallelToolCalls, env = process.env, sessionModelConfig, continuityTrail } = {}) {
  if (!token) throw new Error('DEVIN_CONNECT: missing session token');
  if (!model) throw new Error('DEVIN_CONNECT: missing model selector');
  // P-03: refuse a file/document payload before any encoding. ChatMessage has
  // no attachment field, and messageText() would otherwise flatten the block
  // to '' and let the turn run without the document. This is the last-line
  // guard for direct builder callers; the HTTP handlers reject even earlier.
  const unsupportedFilePart = findFilePartInMessages(messages);
  if (unsupportedFilePart) throw unsupportedFilePartError(unsupportedFilePart);

  // Probe: gated replay of reasoning into tag #11 (or #9 for negative control).
  // Fact A3 (2026-08-03): upstream swe-1-7 accepts reasoning tags (#11, #9) without error
  // but does not consume them (0/3 effect). Kept as permanent gated RE probe tool.
  // Reference req022: genuine Bedrock/Claude client wire where #11 actually lives on assistant turns.
  const replayTag = String(env.DEVIN_CONNECT_REPLAY_REASONING || '').trim();
  const reasoningTagNum = replayTag === '1' ? 11 : (replayTag === '9' ? 9 : 0);

  // System turns normally ride the dedicated system_prompt field (#2). The
  // opt-in collapse mode moves them into the next source=USER message instead,
  // preserving message order while avoiding the stricter field-#2 policy path.
  const collapseSystem = String(env.DEVIN_CONNECT_COLLAPSE_SYSTEM || '').trim() === '1';
  const imageTag = getImageFieldTag(env);
  let systemPrompt = '';
  let continuityCollapsed = false;
  let pendingSystemParts = [];
  const chatMessages = [];

  // Images on an ASSISTANT turn never reach the model: role=2 #10 is accepted on
  // the wire but ignored upstream (measured 2026-10-21, issue #272 — the same
  // image on a user turn answers "Checkerboard", on an assistant turn the model
  // reports no image, on Anthropic and Gemini alike). Two paths therefore
  // DISCARD such images: the empty-turn filter below (image-only turn → dropped
  // whole) and the native tool-call branch (only #6 calls are encoded). Both call
  // this, because issue #272 is about the drop being SILENT, not about the drop.
  // The wire itself is unchanged: admitting an image-only turn would ship bytes
  // the upstream throws away.
  const noteDroppedAssistantImages = (msg) => {
    if (!imageTag) return 0;
    // Remote URLs count too: a direct builder caller can reach this branch with
    // one, and that drop must be named for the same reason (#272 is about the
    // silence, not the drop). streamChat resolves them first, so in production
    // this is the data-URL count.
    const inline = extractInlineImages(msg.content);
    const remote = Array.isArray(msg.content) ? msg.content.filter((b) => remoteImageUrl(b)).length : 0;
    const dropped = inline.length + remote;
    if (!dropped) return 0;
    bumpConnect('assistant_images_dropped', dropped);
    log.warn(`DEVIN_CONNECT: dropped ${dropped} inline image(s) from an assistant turn (#272) — the upstream ignores image fields on role=assistant, so they are not encoded; put the image on a user turn to have the model see it`);
    return dropped;
  };

  const consumeCollapsedSystem = (text = '') => {
    if (!collapseSystem) return text;
    if (!continuityCollapsed && continuityTrail) {
      pendingSystemParts.push(continuityTrail);
      continuityCollapsed = true;
    }
    if (!pendingSystemParts.length) return text;
    const wrapped = `<system>\n${pendingSystemParts.join('\n\n')}\n</system>`;
    pendingSystemParts = [];
    return text ? `${wrapped}\n${text}` : wrapped;
  };

  // Coalesce consecutive same-source text-only turns before wire encoding.
  // Clients that persist streamed output per-part (an agent that splits one
  // turn into several stored entries) emit runs of consecutive same-role
  // text messages — e.g. [assistant,assistant,assistant]. The upstream
  // request validator rejects a same-source run of length >= 3 with
  // invalid_argument ("an internal error occurred") — a run of 2 is
  // tolerated (verified by wire-shape comparison, PR #267 review): the
  // request decodes and begins processing, then the trailer fails. Merging
  // keeps the text identical while shrinking the run below the threshold.
  // Text-only guard: never merge entries carrying tool_calls/tool_call_id,
  // reasoning payloads, or non-text content parts (images) — those encode to
  // distinct wire types and must stay separate.
  const isMergeableText = (m) => {
    if (!m || (m.role !== 'user' && m.role !== 'assistant')) return false;
    if (m.tool_calls?.length || m.tool_call_id) return false;
    if (m.reasoning || m.reasoning_content) return false;
    if (Array.isArray(m.content)) return m.content.every((c) => c?.type === 'text');
    return true;
  };
  const mergedMessages = [];
  for (const m of messages || []) {
    // Default: system turns hoist to field #2 and vanish from the chat
    // sequence, so look past them when judging same-source adjacency.
    // COLLAPSE_SYSTEM=1 keeps them in the sequence (wrapped into the next
    // user), so they break adjacency the same way a real turn does — looking
    // past them would merge the surrounding users and squeeze the system
    // out of its injection slot.
    let pi = mergedMessages.length - 1;
    while (pi >= 0 && mergedMessages[pi].role === 'system' && !collapseSystem) pi--;
    const prev = pi >= 0 ? mergedMessages[pi] : null;
    if (prev && prev.role === m.role && isMergeableText(prev) && isMergeableText(m)) {
      const a = messageText(prev.content);
      const b = messageText(m.content);
      // Fresh object, never mutate caller state — the same `messages` array is
      // re-passed on retry/failover and an in-place merge would double-append.
      mergedMessages[pi] = { ...prev, content: b ? (a ? `${a}\n\n${b}` : b) : a };
      continue;
    }
    mergedMessages.push(m);
  }

  // Name any remote image_url that reached the SYNC builder unresolved. In
  // production streamChat() has already turned those into data URLs
  // (inlineRemoteImages), so this fires only for a direct caller of this
  // function — and it fires instead of the old silent drop. Assistant turns are
  // excluded: their images are dropped on purpose and already reported by
  // noteDroppedAssistantImages (#272), so warning here too would double-report.
  if (imageTag) {
    for (const msg of mergedMessages) {
      if (msg.role === 'system' || msg.role === 'assistant') continue;
      for (const block of Array.isArray(msg.content) ? msg.content : []) {
        const url = remoteImageUrl(block);
        if (!url) continue;
        log.warn(`DEVIN_CONNECT: remote image_url (${imageUrlHost(url)}) reached the wire builder unresolved — the image is NOT sent; streamChat() inlines http(s) images, direct buildGetChatMessageRequest() callers must resolve them first`);
        bumpConnect('remote_image_unresolved');
      }
    }
  }

  for (const msg of mergedMessages) {
    if (msg.role === 'system') {
      const t = messageText(msg.content);
      if (collapseSystem) {
        if (t) pendingSystemParts.push(t);
        continue;
      }
      systemPrompt += systemPrompt ? `\n${t}` : t;
      continue;
    }
    // The Kimi workaround records 10/10 empty retries for empty assistant turns.
    // Preserve that exclusion unless enabled replay supplies a non-blank payload;
    // otherwise a disabled probe would reintroduce a text-empty wire frame.
    //
    // IMAGE-ONLY ASSISTANT TURNS (#272): messageText() reads `text` parts only, so
    // a turn whose whole payload is an inline image arrives here with empty text
    // and is dropped — BEFORE the `images:` extraction at the two encode sites
    // below, which is why the loss used to leave no trace anywhere.
    //
    // The drop itself is DELIBERATE and stays. Measured 2026-10-21 on a paid
    // account: the same image on a role=user turn answers "Checkerboard", while
    // on role=assistant the model reports no image (Anthropic and Gemini alike)
    // even though #10 arrives intact on the wire. Admitting the turn would ship
    // bytes the upstream discards and change the default wire for no capability.
    // What was actually wrong is the SILENCE — issue #272 is filed as "silently
    // dropped" — so the drop is named in the log and counted. If a future
    // upstream starts honouring assistant-role images, this is the single place
    // to change (plus test/wire-h1-empty-assistant.test.js T4 and mutation M5).
    if (msg.role === 'assistant' && messageText(msg.content).trim() === '' && !msg.tool_calls?.length
      && !(reasoningTagNum && String(msg.reasoning || msg.reasoning_content || '').trim())
      && !(msg.reasoning_signature && String(msg.reasoning_content || '').length > 0)) {
      noteDroppedAssistantImages(msg);
      continue;
    }
    const source = msg.role === 'assistant' ? SOURCE.ASSISTANT : SOURCE.USER;
    // Native multi-turn history (nativeToolCall): a prior assistant turn that
    // carried tool_calls, and a role:'tool' result, are encoded as STRUCTURED
    // wire messages (#6 ChatToolCall / role=4 tool_result #7) instead of being
    // folded into text — so the upstream sees a self-consistent native turn
    // history, symmetric with how we decode #6. Gated: only when nativeToolCall
    // is on (else the text-fold path below keeps the emulation wire unchanged).
    if (nativeToolCall && msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
      // With enabled, non-empty replay, text plus k calls repeats reasoning k+1
      // times; without text, k times. This counts calls arriving here, not
      // the client's original batch: preprocessing may already have split it.
      // Keep first-truthy alias precedence. Concatenating equal aliases would
      // duplicate the payload, and changing precedence would rewrite history.
      // Unlike the fallback branch, native replay retains whitespace verbatim.
      const preText = messageText(msg.content);
      // Signed Claude thinking (messages.js, DEVIN_CONNECT_REPLAY_SIGNED_THINKING)
      // rides #11 thinking + #12 signature on EVERY role=2 frame of the turn — the
      // exact shape measured 2026-10-28 (verified upstream, recalled in the tool
      // loop). It wins over the REPLAY_REASONING probe so #11 is never doubled.
      const signedThinking = msg.reasoning_signature && msg.reasoning_content
        ? { text: String(msg.reasoning_content), signature: String(msg.reasoning_signature) }
        : null;
      const turnReasoningTag = signedThinking ? 11 : reasoningTagNum;
      const probeReasoningText = reasoningTagNum && (msg.reasoning || msg.reasoning_content) ? String(msg.reasoning || msg.reasoning_content) : '';
      const reasoningText = signedThinking ? signedThinking.text : probeReasoningText;
      // This branch encodes text and #6 tool calls only — no image slot exists —
      // so any image riding this turn is discarded. Say so (#272).
      noteDroppedAssistantImages(msg);
      if (preText) {
        const parts = [
          writeStringField(1, randomUUID()),
          writeVarintField(2, SOURCE.ASSISTANT),
          writeStringField(3, preText),
        ];
        if (reasoningText) parts.push(writeStringField(turnReasoningTag, reasoningText));
        if (signedThinking) parts.push(writeStringField(12, signedThinking.signature));
        chatMessages.push(Buffer.concat(parts));
      }
      for (const tc of msg.tool_calls) {
        const name = tc.function?.name || tc.name || 'unknown';
        const rawArgs = tc.function?.arguments ?? tc.arguments;
        const argsJson = typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs ?? {});
        chatMessages.push(encodeAssistantToolCall({
          id: tc.id || randomUUID(),
          name,
          argsJson,
          reasoning: reasoningText,
          reasoningTagNum: turnReasoningTag,
          signature: signedThinking ? signedThinking.signature : '',
        }));
      }
      continue;
    }
    if (nativeToolCall && msg.role === 'tool' && msg.tool_call_id) {
      // role=4 tool_result carrying the result text, tied by #7 tool_call_id to
      // the preceding native #6 assistant tool_call (id echoed verbatim).
      chatMessages.push(encodeChatMessage({
        source: SOURCE.TOOL_RESULT,
        text: messageText(msg.content) || '[tool result]',
        images: imageTag ? extractInlineImages(msg.content) : [],
        imageTag,
        toolCallId: msg.tool_call_id,
        env,
      }));
      continue;
    }
    let text = messageText(msg.content);
    if (collapseSystem && msg.role === 'user') {
      text = consumeCollapsedSystem(text);
    }
    // Tool turns have no native slot here; fold them into user text so multi-turn
    // histories that carry tool results still flow through.
    if (msg.role === 'tool') {
      text = `[tool result${msg.tool_call_id ? ` for ${msg.tool_call_id}` : ''}]: ${text}`;
    }
    // Alias precedence matches native replay, but retain this branch's trim:
    // whitespace in the winning alias suppresses emission, not precedence.
    const reasoningText = source === SOURCE.ASSISTANT && reasoningTagNum
      ? String(msg.reasoning || msg.reasoning_content || '').trim()
      : '';
    // Signed Claude thinking on a plain assistant turn: #11 verbatim (never
    // trimmed, the signature seals the exact text) + #12, same as the tool-call
    // branch. Wins over the REPLAY_REASONING probe so #11 is never doubled.
    const signedThinking = source === SOURCE.ASSISTANT && msg.reasoning_signature && msg.reasoning_content
      ? { text: String(msg.reasoning_content), signature: String(msg.reasoning_signature) }
      : null;
    chatMessages.push(encodeChatMessage({
      source,
      text,
      images: imageTag ? extractInlineImages(msg.content) : [],
      imageTag,
      reasoning: signedThinking ? signedThinking.text : reasoningText,
      reasoningTagNum: signedThinking ? 11 : reasoningTagNum,
      signature: signedThinking ? signedThinking.signature : '',
      env,
    }));
  }

  if (collapseSystem && (pendingSystemParts.length || (!continuityCollapsed && continuityTrail))) {
    chatMessages.push(encodeChatMessage({
      source: SOURCE.USER,
      text: consumeCollapsedSystem(''),
      env,
    }));
  }

  // ★ EMPTY-SYSTEM + TOOLS GUARD (2026-07-10, verified from live devin.exe capture).
  // Devin's upstream rejects a Claude-family request that declares tools (#10
  // ToolDef) but carries an EMPTY/absent system prompt (#2) → "an internal error
  // occurred (trace ID ...)". Boundary nailed live: no-system + tools = ERR;
  // empty-string system + tools = ERR; a SINGLE character of system + tools = OK.
  // Real devin.exe always sends a ~20KB system, so it never hit this; a bare
  // API client (curl / a minimal agent) that sends tools without a system did.
  // gpt/swe-family tolerate an empty system; Claude-family does not. When we have
  // tool defs but no system text, inject a minimal benign system so the request is
  // well-formed. Harmless for models that don't need it (one short sentence).
  if (!systemPrompt && Array.isArray(tools) && tools.length > 0) {
    systemPrompt = 'You are a helpful assistant. Use the available tools when appropriate.';
  }
  // T1 reasoning continuity (Thinking-core): append the prior-analysis checkpoint
  // block to the system prompt — the only wire position where context rides without
  // becoming an assistant turn (self-reflection-loop anti-pattern). Byte-compatible:
  // tag #2 just gets longer; absent when the gate is off or the queue is empty.
  //
  // ORDERING vs identity-neutralize (cf. #219 preamble ordering): the neutralize
  // pass rewrites system-role message content in chat.js BEFORE this wire builder
  // runs; this trail is appended here, AFTER it — so the a4-a7 rules never see the
  // trail and the digest reaches the upstream verbatim. That is intentional: the
  // trail's whole purpose is letting the next turn see the prior reasoning as-is.
  if (!collapseSystem && continuityTrail) systemPrompt += continuityTrail;

  // #15 CortexTrajectoryReference — descriptor: exa.cortex_pb
  //   { trajectory_id=1, step_index=2, trajectory_type=3, step_type=4 }.
  // RE-CALIBRATED FROM THE FULL CAPTURE SET (not just req022): decoding all 9
  // GetChatMessage requests of one live session (9501aa2c) shows
  //   #15.1 = a STABLE per-session trajectory UUID (same across every turn),
  //   #15.2 = a MONOTONIC per-session turn counter (req009→022: 1,2,3,4,5,6,7,8),
  //   #15.3 = 4 (trajectory_type = CORTEX_TRAJECTORY_TYPE_CASCADE).
  // The CLI also sends #15.4 step_type = USER_INPUT (14); we omit it (the sibling
  // Go implementation sends it — see the Q7 field map).
  // Stateless default (fresh uuid, turn 1) matches turn-1 capture req009. Since
  // PR #226 the gateway CAN hold a stable session; when it does and the
  // DEVIN_CONNECT_MODEL_CONFIG_STABLE gate is on, chat.js passes the session's
  // {id, turn} via sessionModelConfig so #15 matches devin.exe's shape too.
  const trajectoryRefBuf = Buffer.concat([
    writeStringField(1, sessionModelConfig?.id || randomUUID()),
    writeVarintField(2, sessionModelConfig?.turn || 1),
    writeVarintField(3, 4),
  ]);

  // Null unless the switch resolves ON for this model (gpt-6 defaults on; every
  // other family needs DEVIN_CONNECT_PROMPT_CACHE=1) and the prompt is worth caching.
  const cacheOptionsBuf = buildSystemPromptCacheOptions(systemPrompt, env, model);
  // Both null unless DEVIN_CONNECT_TOOL_CHOICE=1. `disableParallel` is only emitted
  // when the caller explicitly said parallel_tool_calls:false — protobuf omits a
  // false bool anyway, so sending it for the default (true) would be a no-op field.
  const toolChoiceBuf = buildToolChoice(toolChoice, env);
  const toolChoiceTags = getToolChoiceTags(env);
  const disableParallel = isToolChoicePassthroughEnabled(env) && parallelToolCalls === false;

  const parts = [
    writeMessageField(1, buildClientMetadata(token, deviceSeed, userJwt)),
    writeStringField(2, systemPrompt),
  ];
  for (const cm of chatMessages) parts.push(writeMessageField(3, cm));
  parts.push(
    writeVarintField(7, 5),
    writeMessageField(8, buildCompletionConfig(completion)),
    // #11 disable_parallel_tool_calls / #12 tool_choice — opt-in, unconfirmed tags.
    ...(disableParallel ? [writeVarintField(toolChoiceTags.parallel, 1)] : []),
    ...(toolChoiceBuf ? [writeMessageField(toolChoiceTags.choice, toolChoiceBuf)] : []),
    // #13 system_prompt_cache_options — opt-in, see buildSystemPromptCacheOptions.
    ...(cacheOptionsBuf ? [writeMessageField(13, cacheOptionsBuf)] : []),
    writeMessageField(15, trajectoryRefBuf),
    // #16 = cascade_id (descriptor). The value is the cascade the CLI was
    // launched with, NOT a session id; the newer web schema adds `session_id`
    // at #28, which we do not emit.
    writeStringField(16, sessionId || randomUUID()),
    writeVarintField(20, 1),
    writeStringField(21, model),
    // #22 request_id — DELIBERATELY OMITTED. RE-CALIBRATED FROM THE FULL CAPTURE
    // SET: the verified turn-1 request (req009, which the real CLI chats fine)
    // carries NO #22. It only appears from turn 2 onward, where it reuses ONE id
    // across a user-turn's tool loop (turns 2-3 share it, 5-6 share, 7-8 share) —
    // i.e. it's a user-exchange id tied to conversation state, NOT a fresh
    // per-request uuid. The earlier "the real CLI always sends #22" reading came
    // from decoding only req022 (turn 8). Our gateway is stateless (turn-1 every
    // request), so emitting a random #22 matched neither the turn-1 (absent) nor
    // the turn-2+ (reused) wire. Match the verified turn-1 wire: send nothing.
  );
  // Native tool definitions (repeated #10) — only when the inner ToolDef tags are
  // calibrated (DEVIN_CONNECT_TOOL_DEF_TAGS). Default: tag map is null → nothing
  // emitted, tools keep flowing through prompt emulation upstream.
  const toolTags = getToolDefTags(env, { useDefault: nativeToolCall });
  if (toolTags && Array.isArray(tools)) {
    for (const tool of tools) {
      if (tool?.type !== 'function' || !tool.function?.name) continue;
      parts.push(writeMessageField(toolTags.outer, encodeToolDef(tool, toolTags)));
    }
  }
  return Buffer.concat(parts);
}

// ─── Response frame decoding ──────────────────────────────
//
// GetChatMessageResponse wire layout, calibrated against live captures (see
// memory: devin-connect-response-protocol-2026-06-30). reasoning and the final
// answer are NATIVELY SEPARATED into two top-level fields:
//
//   #3  STR  → final answer text     (OpenAI `content`)
//   #5  V    → finish/stop signal    (OpenAI `finish_reason`; 2 == stop)
//   #7  MSG  → metadata { #2 prompt_tokens, #3 completion_tokens, #9 model }
//   #9  STR  → reasoning/thinking    (OpenAI `reasoning_content`)
//
// Earlier code read #9 as the content — that was the thinking stream. The
// answer the caller actually wants is #3.

const FIELD = Object.freeze({ CONTENT: 3, FINISH: 5, META: 7, REASONING: 9 });
// The top-level #5 finish signal maps to the OpenAI finish_reason vocabulary in
// mapFinishReason() below (descriptor-backed; 2 and 4 additionally live-anchored).

// Billing passthrough (GROUNDWORK, opt-in via DEVIN_CONNECT_BILLING_TAGS).
//
// The binary descriptor set (exa.api_server_pb.GetChatMessageResponse /
// exa.codeium_common_pb) pins the paid scalars: credit_cost=14,
// committed_credit_cost=18, committed_acu_cost=22 (double),
// committed_quota_cost_basis_points=26 and committed_overage_cost_cents=27.
// They could NOT be calibrated against a free account: a free tier isn't
// billed, the values are 0, and protobuf does not encode zero-valued scalars —
// so the fields are physically ABSENT from every free-account capture we have,
// and the paid frames seen so far carry no values either.
//
// So billing decode is configuration-driven, default-OFF: nothing is parsed
// until an operator points the map at the tags they want (the descriptor
// coordinates above are the verified starting point), and usage carries no
// billing keys (zero regression). The env var maps logical billing keys to the
// integer tag observed in the metadata sub-message:
//
//   DEVIN_CONNECT_BILLING_TAGS="credit_cost=6,committed_credit_cost=7,committed_acu_cost=8"
//
// All tags are read from the #7 metadata sub-message; readNumericProtoField
// handles the varint / fixed64 / fixed32 encodings.
//
// CONFIRMED 2026-07-23 (paid teams account, live A/B — issue #220): the cache-read
// counter is tag 5. Two requests sharing a long system prefix: round-1 (miss) meta
// dump = {2,3,6}, round-2 (hit) meta dump = {2,3,5,6} with tag5=3840 and tag2(fresh
// input)=436, and 436+3840 == 4276 == round-1's total prompt_tokens. Cross-checked
// against the response's labeled "Cached input tokens" fixed32 (== 3840.0). So
// `DEVIN_CONNECT_BILLING_TAGS=cache_read_tokens=5` is safe on any paid account and
// surfaces prompt_tokens_details.cached_tokens. This also settles #220: caching is
// billed correctly (hit cost measured at 17.8% of miss); the gap was purely that
// the dashboard couldn't SEE the cache split, not that credits were over-spent.
// credit_cost / committed_* now have descriptor-verified coordinates
// (14/18/22/26/27) but no observed values yet, so they stay out of the default map.
// CONFIRMED 2026-07-25 (same paid account): the cache-WRITE counter is tag 4.
// Claude-family selectors split prompt input the way Anthropic's own API does —
// fresh input in tag 2, cache-creation in tag 4 — so a cache-writing turn reported
// prompt_tokens=3 for a 13.4K-token system prompt (99.98% under-reported) because
// tag 4 was never decoded. Live A/B on claude-sonnet-4.6:
//   round-1 (cache write): {2:3, 3:4, 4:14361, 6:4}
//   round-2 (cache read):  {2:3, 3:4, 4:5, 5:14356, 6:4}
// and 14356 (read back) + 5 (the new user turn written) == 14361 (round-1 write),
// which pins tag 4 exactly. GPT-family selectors carry no tag 4 at all (their full
// input rides tag 2, matching OpenAI's no-charge-for-cache-write model), so the
// default is a no-op there rather than a mis-read.
//
// Both tags are calibration-confirmed, so they ship ON by default — otherwise
// prompt_tokens_details.cached_tokens / cache_creation_input_tokens are always 0
// and the dashboard silently mis-attributes cached and cache-written input (#220).
// Safe on free accounts too: the counters are zero there, and protobuf omits
// zero-valued scalars, so the tags are simply absent and nothing is decoded.
// Operators can override the whole map (or drop the defaults) via
// DEVIN_CONNECT_BILLING_TAGS; set it to `off` to decode nothing at all.
const DEFAULT_BILLING_TAGS = 'cache_read_tokens=5,cache_write_tokens=4';

// Billing scalars are not one protobuf wire type. Token counters are varints;
// paid ACU arrives as fixed64/double. Read all three numeric encodings so
// DEVIN_CONNECT_BILLING_TAGS=committed_acu_cost=^22 can see a real double.
// Default map does NOT include that tag — same class as IMAGE_TAG #4.
function readNumericProtoField(fields, tag) {
  for (const f of getAllFields(fields, tag)) {
    let value = null;
    if (f.wireType === 0) value = Number(f.value);
    else if (f.wireType === 1 && f.value.length === 8) value = f.value.readDoubleLE(0);
    else if (f.wireType === 5 && f.value.length === 4) value = f.value.readFloatLE(0);
    if (Number.isFinite(value)) return value;
  }
  return null;
}

function fixedWidthDumpEntry(f) {
  if (f.wireType === 1 && f.value.length === 8) {
    return { kind: 'fixed64', preview: f.value.readDoubleLE(0), raw: f.value.toString('hex') };
  }
  if (f.wireType === 5 && f.value.length === 4) {
    return { kind: 'fixed32', preview: f.value.readFloatLE(0), raw: f.value.toString('hex') };
  }
  return null;
}

function parseBillingTagMap(env = process.env) {
  const configured = String(env.DEVIN_CONNECT_BILLING_TAGS ?? '').trim();
  if (configured.toLowerCase() === 'off') return null;
  const raw = configured || DEFAULT_BILLING_TAGS;
  if (!raw) return null;
  const map = {};
  for (const pair of raw.split(',')) {
    const [key, tag] = pair.split('=').map((s) => s.trim());
    // `^N` reads tag N from the TOP level of the response instead of the #7
    // metadata sub-message, stored as -N so the decode path can tell them apart
    // without a second map. The four .proto reimplementations put credit_cost at
    // top-level #14 (committed_acu_cost #22, quota basis points #26, overage cents
    // #27), while every tag calibrated so far lived in #7 — so both locations have
    // to be expressible. Example, once a paid capture confirms it:
    //   DEVIN_CONNECT_BILLING_TAGS="cache_read_tokens=5,cache_write_tokens=4,credit_cost=^14"
    const topLevel = typeof tag === 'string' && tag.startsWith('^');
    const n0 = Number.parseInt(topLevel ? tag.slice(1) : tag, 10);
    const n = topLevel && Number.isInteger(n0) && n0 > 0 ? -n0 : n0;
    // Only the known billing keys, only nonzero integer tags (negative = top-level).
    // Silently skip garbage so a typo can't crash the hot decode path.
    if (key && Number.isInteger(n0) && n0 > 0 &&
        ['credit_cost', 'committed_credit_cost', 'committed_acu_cost', 'committed_overage_cost_cents',
         // cache-token usage fields (ModelUsageStats.cache_read_tokens /
         // cache_write_tokens). Same un-calibratable-on-free situation; routed
         // into usage (not billing) by decodeFrame. Tags discovered via the
         // DEVIN_CONNECT_DEBUG_META dump on a paid/cached capture.
         'cache_read_tokens', 'cache_write_tokens'].includes(key)) {
      map[key] = n;
    }
  }
  return Object.keys(map).length ? map : null;
}

// Native tool-call DECODE (GROUNDWORK, opt-in via DEVIN_CONNECT_TOOL_CALL_TAGS).
//
// The response carries `delta_tool_calls` (repeated ChatToolCall). The
// descriptor (exa.codeium_common_pb.ChatToolCall) defines, with tags:
//   ChatToolCall {
//     id                   string (1) — OpenAI tool_calls[].id
//     name                 string (2) — OpenAI function.name
//     arguments_json       string (3) — the valid JSON args (OpenAI function.arguments)
//     invalid_json_str     string (4) — raw args the model emitted when NOT valid JSON
//     invalid_json_err     string (5) — parse-error description for invalid_json_str
//     is_custom_tool_call  bool   (6) — custom tool vs a built-in harness tool
//   }
//
// ★ The response ChatToolCall DOES carry the function name: `name = 2`, and the
// live CLI captures populate it (id + name on the first fragment, arguments_json
// on later frames). The earlier "the response item has no name" reading was
// wrong. decodeFrame still falls back to a caller-supplied reverse-lookup
// (opts.toolNames) when a frame omits it, unambiguous when exactly one tool was
// offered. If neither resolves, name is left undefined and the OpenAI builder
// defaults it to 'unknown' (devin-connect-openai.js:127) — shape intact.
//
// Every tag below is descriptor-verified and was independently pinned from
// static disasm of devin.exe (2026-07-04). Default OFF → tool calls keep coming
// from prompt emulation (parseToolCallsFromText), which works everywhere and
// remains the only production path.
//
// Tags PINNED from static disasm of devin.exe (2026-07-04, verified-from-binary):
//   DEVIN_CONNECT_TOOL_CALL_TAGS="outer=6,id=1,name=2,arguments_json=3,invalid_json_str=4,invalid_json_err=5,is_custom_tool_call=6"
// `outer` is the repeated delta_tool_calls tag on the top-level frame (#6, from the
// merge_field jump table); the rest are ChatToolCall subfields, read from prost
// encode_raw @0x1442fe1f0. Re-verify with scripts/re/proto_tags.py. Missing keys
// are simply not read. See .devin-connect-calibrated.env for the pinned values.
// VERIFIED tool_call decode tags (static-disasm pinned from encode_raw
// @0x1442fe1f0 + merge_field jump table). Fallback when nativeToolCall flag is
// on but no explicit env override is set.
export const DEFAULT_CALL_TAGS = Object.freeze({
  outer: 6, id: 1, name: 2, arguments_json: 3,
  invalid_json_str: 4, invalid_json_err: 5, is_custom_tool_call: 6,
});

export function parseToolCallTagMap(env = process.env, { useDefault = false } = {}) {
  const raw = String(env.DEVIN_CONNECT_TOOL_CALL_TAGS || '').trim();
  if (!raw) return useDefault ? { ...DEFAULT_CALL_TAGS } : null;
  const map = {};
  // All 7 subfield keys are verified-from-binary (encode_raw @0x1442fe1f0 + the
  // merge_field jump table). `name` is a real ChatToolCall field (#2), not a guess.
  const allowed = ['outer', 'id', 'name', 'arguments_json',
    'is_custom_tool_call', 'invalid_json_str', 'invalid_json_err'];
  for (const pair of raw.split(',')) {
    const [key, tag] = pair.split('=').map((s) => s.trim());
    const n = Number.parseInt(tag, 10);
    if (allowed.includes(key) && Number.isInteger(n) && n > 0 && n < 536870912) {
      map[key] = n;
    }
  }
  // `outer` is mandatory — without the repeated-field tag there's nothing to read.
  return map.outer ? map : null;
}

// Thinking-signature DECODE config (GROUNDWORK, opt-in). The descriptor pins the
// top-level fields: delta_signature = 10 (string, the encrypted-thinking payload
// increment), delta_signature_type = 21 (enum), thinking_id = 16 (string), and
// thinking_redacted = 11 (bool, not decoded). Free-tier captures never emit them.
// Default OFF: nothing is decoded until an operator opts in, and streamChat
// yields exactly as today (zero regression).
//
//   DEVIN_CONNECT_SIGNATURE_TAG=N           (the delta_signature tag; mandatory)
//   DEVIN_CONNECT_SIGNATURE_TYPE_TAG=N       (delta_signature_type; optional)
//   DEVIN_CONNECT_SIGNATURE_THINKING_ID_TAG=N (thinking_id; optional)
//
// `signature` is mandatory — without the delta_signature tag there is nothing to
// surface, so the whole map is null (decode stays off) when it's unset/garbage.
export function parseSignatureTagMap(env = process.env) {
  const sigTag = Number.parseInt(env.DEVIN_CONNECT_SIGNATURE_TAG || '', 10);
  if (!Number.isInteger(sigTag) || sigTag <= 0 || sigTag >= 536870912) return null;
  const map = { signature: sigTag };
  const typeTag = Number.parseInt(env.DEVIN_CONNECT_SIGNATURE_TYPE_TAG || '', 10);
  if (Number.isInteger(typeTag) && typeTag > 0 && typeTag < 536870912) map.type = typeTag;
  const tidTag = Number.parseInt(env.DEVIN_CONNECT_SIGNATURE_THINKING_ID_TAG || '', 10);
  if (Number.isInteger(tidTag) && tidTag > 0 && tidTag < 536870912) map.thinkingId = tidTag;
  return map;
}

/** A string is "valid JSON" iff JSON.parse accepts it. Used to decide whether the
 * upstream `arguments_json` can be trusted as-is or must fall back to a placeholder. */
function looksLikeValidJson(s) {
  if (typeof s !== 'string' || s.length === 0) return false;
  try { JSON.parse(s); return true; } catch { return false; }
}

/** Decode ONE ChatToolCall sub-message (already parsed into `sub` fields) into
 * { id?, name?, arguments?, isCustom?, invalidJson? }. `arguments` stays the raw
 * JSON STRING — the OpenAI builder passes it through to function.arguments.
 *
 * Fault tolerance mirrors the upstream control_loop.rs:2773 behavior NW1 §2 cites
 * ("Replacing malformed tool-call arguments with {} to prevent chain corruption"):
 *   - valid arguments_json                              → use it verbatim
 *   - arguments_json absent, invalid_json_str present   → emit {} placeholder,
 *                                                          preserve the original
 *                                                          + error as `invalidJson`
 *   - arguments_json present but unparseable, with an
 *     invalid_json signal                               → {} placeholder + invalidJson
 *   - arguments_json present but unparseable, NO signal → keep the raw string
 *                                                          (never silently drop data)
 *
 * NEVER throws: a malformed sub-message is a LOCAL parse problem, not an upstream
 * auth/transient signal. Returning a partial/empty result keeps the transient-first
 * motif intact — a decode miss can't be misread as UNAUTHORIZED and burn a token. */
function decodeOneToolCall(sub, tags) {
  const tc = {};
  if (tags.id) { const v = getField(sub, tags.id, 2); if (v) tc.id = v.value.toString('utf8'); }
  if (tags.is_custom_tool_call) {
    const v = getField(sub, tags.is_custom_tool_call, 0);
    if (v != null) tc.isCustom = Boolean(Number(v.value));
  }
  // name: a CANDIDATE native tag only (response-side ChatToolCall has no verified
  // name field — header note). When present, trust it; otherwise leave undefined for
  // the caller's reverse-lookup / 'unknown' default.
  if (tags.name) {
    const v = getField(sub, tags.name, 2);
    if (v) { const s = v.value.toString('utf8'); if (s) tc.name = s; }
  }

  const rawArgs = tags.arguments_json
    ? (getField(sub, tags.arguments_json, 2)?.value.toString('utf8') ?? null) : null;
  const invalidStr = tags.invalid_json_str
    ? (getField(sub, tags.invalid_json_str, 2)?.value.toString('utf8') ?? null) : null;
  const invalidErr = tags.invalid_json_err
    ? (getField(sub, tags.invalid_json_err, 2)?.value.toString('utf8') ?? null) : null;

  if (rawArgs != null && looksLikeValidJson(rawArgs)) {
    tc.arguments = rawArgs;
  } else if (rawArgs != null && (invalidStr != null || invalidErr != null)) {
    // Upstream args are malformed; downgrade to {} so the tool-call chain doesn't
    // corrupt, but keep the original + error around for debugging/passthrough.
    tc.arguments = '{}';
    tc.invalidJson = { str: invalidStr ?? rawArgs, err: invalidErr ?? null };
  } else if (rawArgs != null) {
    // Unparseable and no explicit invalid_json signal: never drop data — keep raw.
    tc.arguments = rawArgs;
  } else if (invalidStr != null || invalidErr != null) {
    // No arguments_json at all, only the malformed-args signal: {} placeholder.
    tc.arguments = '{}';
    tc.invalidJson = { str: invalidStr, err: invalidErr ?? null };
  }
  return tc;
}

/** Decode repeated ChatToolCall sub-messages from a frame, given calibrated tags
 * and an optional request-side tool list for name reverse-lookup.
 * Returns [{ id?, name?, arguments?, isCustom?, invalidJson? }] or [].
 *
 * @param {Array} fields    top-level parsed frame fields
 * @param {object} tags     calibrated tag map (parseToolCallTagMap)
 * @param {string[]} [toolNames]  request-side tool names, for the name fallback */
/** Coalesce a per-frame ChatToolCall fragment into the running accumulator.
 *
 * FRAME-VERIFIED 2026-07-05 (paid opus-4-8 native tool_call capture): a single
 * logical tool call is streamed across MULTIPLE frames — the first frame carries
 * {id, name}, then later frames carry ONLY an `arguments_json` fragment (#6.3)
 * with no id (`{"patter` → `n": "DEV` → `IN_CONNECT"}`). The naive "push every
 * decoded item" collapses one call into several broken half-calls and truncates
 * the JSON. So merge by id: a fragment carrying an id (distinct from the open
 * call) starts a new call; an id-less fragment appends its argument bytes to the
 * currently-open call. Concatenating the raw fragments reconstructs the full,
 * valid arguments JSON. (audit §4 P3 — per-frame non-coalescing.) */
export function mergeToolCallFragment(acc, tc) {
  const open = acc.length ? acc[acc.length - 1] : null;
  const startsNew = !open || (tc.id && tc.id !== open.id);
  if (startsNew) {
    acc.push({ ...tc, arguments: tc.arguments ?? '' });
    return;
  }
  if (tc.id && !open.id) open.id = tc.id;
  if (tc.name && !open.name) open.name = tc.name;
  if (tc.isCustom != null && open.isCustom == null) open.isCustom = tc.isCustom;
  if (tc.arguments) open.arguments = (open.arguments || '') + tc.arguments;
  if (tc.invalidJson) open.invalidJson = tc.invalidJson;
}

function decodeToolCalls(fields, tags, toolNames = null) {
  const out = [];
  // The single-tool reverse-lookup: if exactly one tool was offered this turn,
  // a response ChatToolCall (which carries no name) is unambiguously that tool.
  const soleName = (Array.isArray(toolNames) && toolNames.length === 1)
    ? toolNames[0] : null;
  for (const f of getAllFields(fields, tags.outer)) {
    if (f.wireType !== 2) continue;
    let sub;
    try { sub = parseFields(f.value); }
    catch { continue; } // malformed sub-message → skip this item, never throw
    const tc = decodeOneToolCall(sub, tags);
    // name fallback: native CANDIDATE tag (already set) → sole-tool reverse-lookup.
    if (!tc.name && soleName) tc.name = soleName;
    // Keep any item that carried real content (id / name / args / a malformed-args
    // signal). isCustom alone (a bare bool) isn't enough to count as a call.
    if (tc.id || tc.name || tc.arguments || tc.invalidJson) out.push(tc);
  }
  return out;
}

/**
 * Calibration-only: decode the INNER fields of a non-printable top-level
 * sub-message into `out[field] = { <innerTag>: {kind, preview} }`. This is how a
 * paid/tool/router capture reveals the structure of opaque trailers like the
 * recurring #28 (usage/billing/stop-metadata) that the flat frame dump can only
 * mark as "<msg Nb>". Read-only, best-effort: a sub-message that isn't valid
 * protobuf (e.g. a genuinely opaque encrypted blob) is skipped silently so the
 * calibration path can never throw on the hot stream. Mirrors the flat dump's
 * kind/preview contract: varint→number, printable len-delim→utf8 preview,
 * non-printable len-delim→"<msg Nb>", fixed32/64→hex preview.
 *
 * @param {{field:number,value:Buffer}} f  the top-level sub-message field
 * @param {Object<number,object>} into     accumulator keyed by top-level tag
 */
// How deep to recurse into nested sub-messages when dumping. The recurring #28
// "Response Statistics" trailer nests the real usage/billing counters one level
// down (#28.2), and #7.8 nests further — a flat one-level decode would only mark
// those "<msg Nb>". Depth-capped so a pathological / mis-parsed blob can't recurse
// unbounded; this is an opt-in (dumpMeta) diagnostic path only.
const SUB_DUMP_MAX_DEPTH = 4;

// Decode the inner fields of a protobuf sub-message into {tag: {kind, preview}}.
// A non-printable length-delimited field is itself likely a nested message: recurse
// (up to SUB_DUMP_MAX_DEPTH) and attach the decoded children under `.fields` while
// still recording the presence preview, so `#28.2`'s real counters surface in one
// capture. Returns the bucket, or null if the buffer isn't parseable protobuf.
function decodeInnerFields(buf, depth) {
  let inner;
  try { inner = parseFields(buf); }
  catch { return null; } // not protobuf → opaque (caller keeps the flat presence note)
  if (!inner.length) return null;
  const bucket = {};
  for (const sf of inner) {
    if (sf.wireType === 0) bucket[sf.field] = { kind: 'varint', preview: Number(sf.value) };
    else if (sf.wireType === 2) {
      const s = sf.value.toString('utf8');
      if (/^[\x20-\x7e]*$/.test(s) && s.length) bucket[sf.field] = { kind: 'string', preview: s.slice(0, 48) };
      else {
        const entry = { kind: 'message', preview: `<msg ${sf.value.length}b>` };
        // Retain the FULL raw bytes (hex) of every non-printable sub-message. The
        // 48-char preview above threw the tail away — that is exactly how #28.2's
        // 40-byte counter block was lost on the last capture. With the complete
        // hex here, a single text-only PAID capture is enough to reverse ALL inner
        // tags OFFLINE, collapsing "one PAID probe per field" into a single closed
        // loop. This path is only reached under dumpMeta (via decodeSubMessage), so
        // it stays a diagnostic-only cost with zero effect on production decoding.
        entry.raw = sf.value.toString('hex');
        if (depth < SUB_DUMP_MAX_DEPTH) {
          const nested = decodeInnerFields(sf.value, depth + 1);
          if (nested) entry.fields = nested; // one level deeper decoded
        }
        bucket[sf.field] = entry;
      }
    } else if (sf.wireType === 5) bucket[sf.field] = { kind: 'fixed32', preview: sf.value.toString('hex') };
    else if (sf.wireType === 1) bucket[sf.field] = { kind: 'fixed64', preview: sf.value.toString('hex') };
  }
  return Object.keys(bucket).length ? bucket : null;
}

function decodeSubMessage(f, into) {
  const bucket = decodeInnerFields(f.value, 1);
  if (bucket) into[f.field] = bucket;
}

/**
 * Decode one response frame into the deltas it carries. Any field may be absent
 * on a given frame (metadata-only frames are common at the head of the stream).
 *
 * @param {Buffer} payload
 * @param {object} [opts]
 * @param {object|null} [opts.billingTags]  logical-key → metadata tag number map
 *                                           (from parseBillingTagMap); null = off.
 * @param {object|null} [opts.toolCallTags]  ChatToolCall tag map (parseToolCallTagMap);
 *                                            null = off → native tool calls not decoded.
 * @param {string[]|null} [opts.toolNames]   request-side tool names, for the name
 *                                            reverse-lookup (response ChatToolCall has none).
 * @returns {{ content: string, reasoning: string, finish: number|null,
 *             usage: {prompt: number, completion: number}|null,
 *             billing: object|null }}
 */
export function decodeFrame(payload, opts = {}) {
  // Malformed / truncated upstream protobuf must never bubble out of here: this
  // runs inside the res.on('data') callback (devin-connect.js:1097), and a synchronous
  // throw there escapes the streamChat generator's try/finally and lands as an
  // uncaughtException → index.js process.exit(1), tearing down every concurrent
  // tenant's connection over one bad frame from the upstream / US-proxy. parseFields
  // throws on unknown wire types (3/4/6/7) and truncated varints/len-delims; treat any
  // such frame as an empty delta and skip it, matching the defensive contract already
  // applied on the parseTrajectorySteps hot path (audit FRAME-1).
  let fields;
  try { fields = parseFields(payload); }
  catch (err) {
    log.warn(`DEVIN_CONNECT: skipping malformed frame (parse failed): ${err.message}`);
    return { content: '', reasoning: '', finish: null, usage: null, billing: null };
  }
  const content = getField(fields, FIELD.CONTENT, 2);
  const reasoning = getField(fields, FIELD.REASONING, 2);
  const finish = getField(fields, FIELD.FINISH, 0);
  const meta = getField(fields, FIELD.META, 2);

  // actual_model_uid: the concrete model that served the turn (differs from the
  // requested selector for router models — adaptive/arena-*). FRAME-VERIFIED
  // 2026-07-05 (paid teams, opus-4-8): it rides the #7 metadata sub-message at
  // INNER tag 9 (#7.9 echoed "claude-opus-4-8-medium"), NOT a top-level field —
  // so it is decoded inside the `if (meta)` block below from `mf`. Opt-in via
  // DEVIN_CONNECT_ACTUAL_MODEL_TAG (calibrated value = 9). Off = null, no change.
  let actualModel = null;

  // Thinking signature (delta_signature / delta_signature_type / thinking_id):
  // top-level GetChatMessageResponse fields, the SAME layer as delta_thinking
  // (#9) — see NATIVE-WIRE-FIELDS §5. The reverse-engineering CONFIRMED the
  // field names [verified]; the earlier "upstream never produces a signature"
  // belief was wrong. But free swe-1.6 frames never carry them (free tier
  // produces no thinking signature), so the wire TAGS are [unknown] from every
  // capture we have. Opt-in via opts.signatureTags (default OFF → never decoded,
  // zero behavioral change). `delta_signature` is a declared string field — an
  // opaque encrypted payload the client only round-trips — so it is surfaced
  // verbatim as utf8 with NO printable filter (a base64 signature is preserved
  // byte-for-byte; mangling it would break the round-trip the upstream expects).
  let signature = null;
  if (opts.signatureTags && opts.signatureTags.signature) {
    const sig = getField(fields, opts.signatureTags.signature, 2);
    if (sig) {
      signature = { text: sig.value.toString('utf8') };
      // delta_signature_type: enum/int distinguishing anthropic vs gemini vs none.
      if (opts.signatureTags.type) {
        const t = getField(fields, opts.signatureTags.type, 0);
        if (t) signature.signatureType = Number(t.value);
      }
      // thinking_id: binds the signature to a specific thinking block (string).
      if (opts.signatureTags.thinkingId) {
        const ti = getField(fields, opts.signatureTags.thinkingId, 2);
        if (ti) {
          const s = ti.value.toString('utf8');
          if (/^[\x20-\x7e]+$/.test(s)) signature.thinkingId = s; // printable id only
        }
      }
    }
  }

  let usage = null;
  let billing = null;
  let metaDump = null;
  let topLevelDump = null;
  if (meta) {
    // Same defensive contract as the top-level parse: the #7 metadata sub-message
    // is length-delimited so its bounds are valid, but its CONTENTS can still be
    // malformed protobuf (unknown wire type / truncated varint). A throw here would
    // escape res.on('data') → uncaughtException just the same, so treat an
    // unparseable meta block as "no usage/billing" rather than crashing (audit FRAME-1).
    let mf;
    try { mf = parseFields(meta.value); }
    catch (err) { log.warn(`DEVIN_CONNECT: skipping malformed frame metadata: ${err.message}`); mf = []; }
    const prompt = getField(mf, 2, 0);
    const completion = getField(mf, 3, 0);
    // actual_model_uid at #7.9 (frame-verified) — read from the metadata block.
    if (opts.actualModelTag) {
      const am = getField(mf, opts.actualModelTag, 2);
      if (am) {
        const s = am.value.toString('utf8');
        if (/^[\x20-\x7e]+$/.test(s)) actualModel = s; // printable selector only
      }
    }
    // completion_tokens only rides the final metadata frame; treat the pair as
    // usage only when the completion count is present.
    if (completion) {
      usage = { prompt: prompt ? prompt.value : 0, completion: completion.value };
    }
    // Billing/usage passthrough: opt-in, only when an operator has pinned the
    // tags. Each is a varint; absent fields (free tier / un-billed / un-cached)
    // yield nothing. cache_*_tokens are usage stats → folded into `usage`; the
    // cost fields are billing → into `billing`.
    const billingTags = opts.billingTags;
    if (billingTags) {
      for (const [key, tag] of Object.entries(billingTags)) {
        // A NEGATIVE tag means "read this from the TOP level, not from #7".
        // Four independent .proto reimplementations put credit_cost at top-level
        // #14 (with committed_acu_cost #22 and the quota basis-point / overage-cent
        // pair at #26/#27), i.e. NOT in the #7 metadata sub-message where every
        // calibrated tag so far has lived. That is a coordinate, not a measurement:
        // these fields are still zero-and-therefore-absent on a free account, so the
        // .protos tell us WHERE to look while a paid capture is still what confirms
        // the value. Encoding the location as `credit_cost=^14` lets an operator
        // point at the top level without a second env var, and keeps the sub-message
        // default untouched for the tags that were measured there.
        if (tag < 0) continue; // handled in the top-level pass below
        const value = readNumericProtoField(mf, tag);
        if (value == null) continue;
        if (key === 'cache_read_tokens' || key === 'cache_write_tokens') {
          (usage ||= { prompt: prompt ? prompt.value : 0, completion: completion ? completion.value : 0 })[key] = value;
        } else {
          (billing ||= {})[key] = value;
        }
      }
    }
    // Calibration hook (opt-in): expose EVERY varint subfield of the #7 metadata
    // sub-message as {tag: value}. This is how an operator discovers the unknown
    // tags for cache_read_tokens / cache_write_tokens / credit_cost etc. from a
    // real capture — the recon has the field NAMES (ModelUsageStats) but not the
    // integer tags. Off by default; pure read, no behavioral effect.
    if (opts.dumpMeta) {
      metaDump = {};
      for (const f of mf) {
        if (f.wireType === 0) metaDump[f.field] = Number(f.value);
      }
    }
  }

  // Top-level billing pass. Runs OUTSIDE the `if (meta)` block on purpose: a
  // response can carry credit_cost at the top level with no #7 sub-message at all
  // (the sub-message holds token counts, and a billed turn is not obliged to
  // report them), so nesting this would silently skip exactly the frames it
  // exists for. Same varint-or-nothing rule as the sub-message pass.
  if (opts.billingTags) {
    for (const [key, tag] of Object.entries(opts.billingTags)) {
      if (tag >= 0) continue; // sub-message tags were handled above
      const value = readNumericProtoField(fields, -tag);
      if (value == null) continue;
      if (key === 'cache_read_tokens' || key === 'cache_write_tokens') {
        // Token counts stay usage, wherever they were read from. Routing them into
        // `billing` because of the location they were pinned at would move
        // prompt_tokens_details.cached_tokens out of usage and silently zero the
        // dashboard's cache split — the exact mis-attribution #220 was about.
        // prompt/completion live inside the `if (meta)` block above, so recompute
        // the usage defaults here rather than reference block-scoped locals.
        (usage ||= { prompt: Number(getField(fields, 2, 0)?.value ?? 0), completion: Number(getField(fields, 3, 0)?.value ?? 0) })[key] = value;
      } else {
        (billing ||= {})[key] = value;
      }
    }
  }
  // Calibration hook for the top level, mirroring dumpMeta for the sub-message.
  // The .protos name top-level #14/#22/#26/#27 but declaration order is not a wire
  // tag, so an operator still has to see a real paid frame to pin them. This prints
  // every top-level varint EXCEPT the ones already decoded by name, so the output
  // is a short list of candidates rather than a haystack.
  if (opts.dumpMeta) {
    topLevelDump = {};
    for (const f of fields) {
      if (f.wireType === 0 && f.field !== FIELD.FINISH) topLevelDump[f.field] = Number(f.value);
    }
  }

  const out = {
    content: content ? content.value.toString('utf8') : '',
    reasoning: reasoning ? reasoning.value.toString('utf8') : '',
    // wire-01: raw content/reasoning bytes for the streaming path. A multi-byte
    // UTF-8 char (Chinese / emoji) can be split across two frames; decoding each
    // frame in isolation with toString('utf8') turns the split char into U+FFFD
    // garbage. The streaming consumer feeds these Buffers through a StringDecoder
    // that holds an incomplete trailing sequence until the next frame completes
    // it. Non-streaming callers keep using the per-frame `content` string above
    // (a whole message in one frame decodes identically either way).
    contentBytes: content ? content.value : null,
    reasoningBytes: reasoning ? reasoning.value : null,
    finish: finish ? finish.value : null,
    usage,
    billing,
  };
  if (actualModel) out.actualModel = actualModel;
  // Thinking signature (opt-in): surfaced only when DEVIN_CONNECT_SIGNATURE_TAG
  // is calibrated. Off → nothing added, the signature_delta passthrough in
  // messages.js keeps using its empty-string placeholder (forward-compatible).
  if (signature) out.signature = signature;
  // Native tool calls (opt-in): decode repeated ChatToolCall sub-messages when the
  // response-side tags are calibrated. Off → nothing added, prompt emulation owns
  // tool calls as today.
  if (opts.toolCallTags) {
    const calls = decodeToolCalls(fields, opts.toolCallTags, opts.toolNames);
    if (calls.length) out.toolCalls = calls;
  }
  if (metaDump) out.metaDump = metaDump;
  if (topLevelDump) out.topLevelDump = topLevelDump;
  // Top-level frame calibration: when dumping, also surface every top-level
  // field so unknown tags like `actual_model_uid` (the concrete model that
  // served a router request) are discoverable. varints → numbers, short
  // length-delimited → utf8 preview; the rest noted by wire type.
  if (opts.dumpMeta) {
    const frameDump = {};
    const subDump = {};
    for (const f of fields) {
      if (f.wireType === 0) frameDump[f.field] = Number(f.value);
      else if (f.wireType === 1 || f.wireType === 5) {
        const entry = fixedWidthDumpEntry(f);
        if (entry) frameDump[f.field] = entry;
      }
      else if (f.wireType === 2 && f.value.length <= 64) {
        const s = f.value.toString('utf8');
        if (/^[\x20-\x7e]+$/.test(s)) frameDump[f.field] = s; // printable preview only
        else { frameDump[f.field] = `<msg ${f.value.length}b>`; decodeSubMessage(f, subDump); } // non-printable sub-message → PRESENCE + inner decode
      } else if (f.wireType === 2) {
        // Oversized field: only mark presence if it's a binary sub-message, not a
        // long printable string (those stay out, per the dump's preview contract).
        const s = f.value.toString('utf8');
        if (!/^[\x20-\x7e]+$/.test(s)) { frameDump[f.field] = `<msg ${f.value.length}b>`; decodeSubMessage(f, subDump); }
      }
    }
    if (Object.keys(frameDump).length) out.frameDump = frameDump;
    // Inner fields of every non-printable top-level sub-message. This is what
    // turns the recurring #28 trailer (a 186b usage/billing/stop-metadata block
    // that the flat dump could only mark as "<msg 186b>") into DISCOVERABLE
    // {tag: {kind, preview}} — so `calibrate:devin` surfaces its guts in one run
    // instead of needing a hand-written probe. Opt-in (dumpMeta), additive, and
    // the flat frameDump above is unchanged → zero regression for existing
    // consumers (only the calibrate harness reads subDump).
    if (Object.keys(subDump).length) out.subDump = subDump;
  }
  return out;
}

/**
 * Classify an upstream error body/code into a stable, caller-mappable shape.
 * Cases that matter for routing decisions in chat.js:
 *   - a free-tier account asking for a paid selector → "/upgrade to access..."
 *     surfaces as MODEL_BLOCKED so the handler returns 402 and does NOT penalize
 *     the account (it's a tier wall, the account itself is healthy).
 *   - a PAID account that has run out of credit/quota → QUOTA_EXHAUSTED. This is
 *     account-specific and must be cooled down (otherwise getApiKey keeps
 *     re-selecting a dry account that 402s every client). Distinct from the tier
 *     wall above, which would wrongly demote a healthy free account.
 *   - auth failures (permission_denied / 401) → UNAUTHORIZED.
 * Everything else keeps its upstream code (or UPSTREAM_ERROR).
 *
/**
 * Parse a Go-duration reset window out of a rate-limit message into ms.
 * The backend renders retry-after with Go's time.Duration.String() — e.g.
 * "Resets in: 3h0m0s", "Resets in: 1m30s", "resets in 45s". Returns the total
 * milliseconds, or null when no parseable duration is present (caller then falls
 * back to its default cooldown). Bounded to a sane ceiling so a bogus huge value
 * can't cool an account down for days.
 */
export function parseResetDuration(text) {
  const m = /resets? in[:\s]+((?:\d+h)?(?:\d+m)?(?:\d+(?:\.\d+)?s)?)/i.exec(String(text || ''));
  if (!m || !m[1]) return null;
  const dur = m[1];
  const h = /(\d+)h/.exec(dur);
  const min = /(\d+)m(?!s)/.exec(dur); // m not followed by s (avoid matching "ms")
  const s = /(\d+(?:\.\d+)?)s/.exec(dur);
  if (!h && !min && !s) return null;
  let ms = 0;
  if (h) ms += Number(h[1]) * 3600_000;
  if (min) ms += Number(min[1]) * 60_000;
  if (s) ms += Number(s[1]) * 1000;
  if (ms <= 0) return null;
  // Ceiling at 6h — the observed window is 3h; guard against a garbage value.
  return Math.min(ms, 6 * 3600_000);
}

// gRPC 4xx-class statuses that mean "the REQUEST itself is invalid" — the
// upstream request VALIDATOR refused these bytes. `invalid_argument` is measured
// for both an unsupported image (evidence/AccountMatrix.md §7.1) and a malformed
// tool history (#273.1: "There is an issue with this request, please try a
// different model."); `failed_precondition` is its sibling status. The quota-dry
// body also rides `failed_precondition`, which is why the narrower QUOTA_EXHAUSTED
// text arm runs BEFORE these codes are claimed.
//
// Permanent by construction: identical bytes fail identically on every retry, and
// nothing here is evidence about the account or its credential.
const REQUEST_REJECTION_CODES = new Set(['invalid_argument', 'failed_precondition']);
function isRequestRejectionCode(code) {
  return typeof code === 'string' && REQUEST_REJECTION_CODES.has(code);
}

/**
 * @param {string} text   raw body or trailer message
 * @param {string|null} code  upstream code if already known
 * @param {number|null} status  HTTP status if a non-200 was seen
 * @returns {{code: string, message: string, resetMs?: number}}
 */
export function classifyUpstreamError(text, code = null, status = null) {
  const body = String(text || '').trim();
  const lc = body.toLowerCase();

  // ── TRANSIENT-FIRST (the #56/#57 family) ─────────────────────────────────
  // The upstream wraps TRANSIENT faults (capacity, backend errors) inside a
  // 401/403 auth-shell. Every transient pattern MUST be matched BEFORE the
  // QUOTA / MODEL_BLOCKED / UNAUTHORIZED branches — otherwise a momentary blip
  // reads as a dead token or a permanent tier-wall, triggers a needless
  // re-login on a live token, and a second hit escalates to a permanent
  // MODEL_BLOCKED → a working free account is burned over a retryable hiccup.

  // The gRPC `internal` code is for PERMANENT client mistakes (short
  // fingerprint, gzipped request body) — fails identically every retry, so it
  // is NOT the transient backend fault below. Keep it non-retryable.
  if (code === 'internal') {
    return { code: 'UPSTREAM_ERROR', message: body || 'DEVIN_CONNECT: internal (client request rejected)' };
  }
  // HARD per-model rate limit with an explicit reset window. Observed live
  // (paid teams, 2026-07-08): "Reached message rate limit for this model. Please
  // try again later. Resets in: 3h0m0s". This is a real account-scoped throttle
  // the backend hands back with a Go-duration retry-after — NOT the transient
  // "high demand" capacity blip below. It MUST be matched BEFORE the CAPACITY
  // branch, because that branch's `try again later` sub-pattern also matches this
  // text and would misclassify it as CAPACITY → which is RETRYABLE + a 60s
  // cooldown. Retrying into a 3h hard limit just amplifies load against an already
  // throttled account (exactly how a single-account pool self-inflicts an outage).
  // We surface it as RATE_LIMITED (non-retryable) and parse the reset window onto
  // resetMs so the handler can cool the account for the REAL duration.
  if (/(message |request )?rate limit(ed)?/i.test(lc) && /resets? in[:\s]/i.test(lc)) {
    const resetMs = parseResetDuration(lc);
    const out = { code: 'RATE_LIMITED', message: body || 'DEVIN_CONNECT: message rate limit reached' };
    if (resetMs != null) out.resetMs = resetMs;
    return out;
  }
  // rel-01: an EXPLICIT HTTP 429 (or gRPC resource_exhausted) is an authoritative
  // account-scoped rate-limit signal from the transport layer — it MUST win over
  // the text-based CAPACITY heuristic below. Otherwise a 429 whose body happens to
  // contain "try again later" / "capacity" / "overloaded" gets read as CAPACITY
  // (retryable + a short 60s cooldown), so the pool retries straight back into a
  // real rate limit, amplifying load and under-cooling the account. Classifying it
  // as RATE_LIMITED (non-retryable) lets the cross-account failover + cooldown do
  // their job. The hard "...Resets in: 3h" branch above already ran, so any
  // parseable reset window has been honored; a bare 429 falls here.
  if (status === 429 || code === 'resource_exhausted') {
    return { code: 'RATE_LIMITED', message: body || 'DEVIN_CONNECT: rate limited (HTTP 429)' };
  }
  // Capacity / high-demand throttling. Observed live in a 401/403 shell:
  // "We're currently facing high demand for this model. Please try again later."
  // Widened to cover bare "service/backend/model unavailable" and "overloaded"
  // (audit F2) — the same delivery mode with a different upstream word.
  if (/high demand|try again later|currently (busy|overloaded|at capacity)|model is (busy|overloaded)|temporarily (busy|overloaded|unavailable)|server is busy|overloaded|(service|backend|model|server) (is )?(temporarily )?unavailable|capacity/i.test(lc)
      || code === 'unavailable') {
    return { code: 'CAPACITY', message: body || 'DEVIN_CONNECT: model temporarily at capacity' };
  }
  // "an internal error occurred (trace ID: ... / error ID: ...)" is a TRANSIENT
  // upstream BACKEND fault, NOT a dead session token — even in a 401/403 shell.
  // Observed live (free account <redacted>): GetUserStatus + liveness
  // both pass while completions return this 3/3 with fresh trace IDs. Distinct
  // from the gRPC `internal` code handled above.
  //
  // The TEXT alone is not the verdict: an explicit 4xx-class VALIDATOR code wins
  // over it. Measured 2026-10-25 (evidence/AccountMatrix.md §7.1), an image sent
  // to a model whose /v1/models row OMITS `supports_images` returned a 106-byte
  // trailer `{"error":{"code":"invalid_argument","message":"an internal error
  // occurred"}}` — the validator refusing these exact bytes, identical on every
  // retry. Claiming it here surfaced a permanent capability rejection as 503
  // UPSTREAM_INTERNAL and charged the internal-error breaker. See the
  // REQUEST_REJECTED arm at the end of this function.
  if (/internal error occurred/i.test(lc) && !isRequestRejectionCode(code)) {
    return { code: 'UPSTREAM_INTERNAL', message: body || 'DEVIN_CONNECT: upstream internal error' };
  }
  // CONTENT POLICY block (2026-07-10, live-confirmed). Upstream returns
  // `permission_denied` with "blocked by our content policy / remove sensitive or
  // unsafe content from your prompt" when the REQUEST CONTENT trips Devin's policy
  // (observed: a full Claude-Code system prompt with git status + security-flavored
  // commit messages). This is a PER-REQUEST content rejection, NOT an auth failure
  // — the session token is perfectly alive (a plain prompt on the same account
  // succeeds immediately after). It MUST be matched BEFORE the UNAUTHORIZED branch
  // below, because that branch's `permission_denied` pattern would otherwise read
  // it as a dead token → re-login storm + the account cooled/failed-over → a live
  // account wrongly benched and the client told "all accounts exhausted (dead
  // session tokens)" instead of the real "your content was blocked". Non-retryable
  // (retrying identical content just re-trips the policy) and NO account penalty.
  if (/blocked by (our |the )?content policy|remove (sensitive|unsafe) content|content[_ ]policy/i.test(lc)) {
    return { code: 'CONTENT_BLOCKED', message: body || 'DEVIN_CONNECT: request blocked by upstream content policy' };
  }

  // ── ACCOUNT-STATE / PERMANENT ────────────────────────────────────────────
  // Out-of-credit/quota is an ACCOUNT state (cool it down), checked before the
  // tier-wall pattern so "insufficient credit" never reads as a free-tier
  // /upgrade prompt. "entitlement" stays a tier wall (you lack the plan, not
  // the balance), matching the /upgrade semantics.
  //
  // `(quota|credit|usage|balance|allowance).*exhausted` is load-bearing and was
  // measured missing. The live body for a Pro account whose daily bucket is dry is
  //
  //   "Your daily usage quota has been exhausted. Visit
  //    https://app.devin.ai/settings/usage ... (trace ID: …)"
  //
  // carried on gRPC code `failed_precondition`. The old pattern only knew
  // `quota.*exceeded`, so that body matched NOTHING here, fell through to the
  // verbatim passthrough below, and came back as `failed_precondition`. That code
  // is (a) rendered as HTTP 502 `upstream_error` by connectErrorToHttp, (b) NOT in
  // isAccountFailoverError, so a dry account never failed over to a funded peer,
  // and (c) in TRANSPORT_FAULT_CODES, so every quota-dry call incremented the
  // internal-error breaker (2 hits → 120 s quarantine). One missing word turned a
  // routine per-account dry-well into a client-visible 502 plus a benched account.
  //
  // Also covers the on-demand shapes ("Your on-demand usage has been exhausted",
  // "on-demand balance exhausted") and the balance/allowance variants, all of
  // which are the same account state. Ordered after CAPACITY and CONTENT_BLOCKED
  // so those keep their narrower meanings.
  if (code === 'insufficient_quota'
      || /insufficient.*(credit|quota|balance|funds)/i.test(lc)
      || /out of (credit|quota|balance|funds)/i.test(lc)
      || /(credit|quota|balance|allowance|usage)[\s\w-]{0,40}?(exceeded|exhausted|depleted|used up)/i.test(lc)
      || /on.?demand[^.]{0,60}(exhausted|exceeded|depleted|out of|insufficient)/i.test(lc)
      || /run(?:ning)? out of (credit|quota|balance|funds|usage)/i.test(lc)) {
    return { code: 'QUOTA_EXHAUSTED', message: body || 'DEVIN_CONNECT: account out of credit/quota' };
  }
  // ── MODEL-LEVEL rejection scoped to (ACCOUNT, SELECTOR) ──────────────────
  // Measured body: "This model is no longer available for your account",
  // carried on gRPC `permission_denied` / HTTP 401. The session token is ALIVE
  // — the same account serves every other selector — so this must NOT reach
  // the UNAUTHORIZED arm below. Before this branch existed it did, and the
  // whole chain was wrong for it:
  //   (a) finalizeConnectAccount charged the account's error budget, so three
  //       requests for one retired selector flipped a perfectly healthy
  //       account to status='error' (persisted) — a pool-wide blackout for
  //       every OTHER model the account could still serve;
  //   (b) the same arm fired a re-login for a token that was never dead, and
  //       the handler's dead-token path ran requestReauthCheck, i.e. an
  //       account-scoped reauth verdict from a selector-scoped fact;
  //   (c) the request never failed over, because isAccountFailoverError only
  //       knew QUOTA_EXHAUSTED / RATE_LIMITED.
  // The honest scope is the PAIR: this canonical selector is no longer
  // entitled for THIS account, while the account stays healthy for the rest.
  // Checked BEFORE the /upgrade tier-wall arm so a per-account signal wins
  // over an inferred tier wall (a tier wall is shared by every account of a
  // tier; this one is not, so a peer may well serve it).
  //
  // NARROWED to the measured account-scoped wording. This arm used to also
  // accept plan/tier/subscription/workspace and the "not available" variant,
  // which reclassified an explicit tier wall ("… no longer available for your
  // plan. Please /upgrade …") as a per-account selector verdict. The two codes
  // route differently: MODEL_BLOCKED is excluded from failover on purpose
  // (every account of that tier hits the same wall), while
  // SELECTOR_NOT_ELIGIBLE hops and latches. Those nouns are not evidence
  // about ONE account; only "your account" is.
  if (/(?:this |the )?(?:model|selector)\b[^.]{0,60}?\bno longer available for your account\b/i.test(lc)) {
    return { code: 'SELECTOR_NOT_ELIGIBLE', message: body || 'DEVIN_CONNECT: model not available for this account' };
  }
  if (/\/upgrade|upgrade to access|insufficient.*entitlement|requires? .*(paid|pro|team|enterprise)/i.test(lc)) {
    return { code: 'MODEL_BLOCKED', message: body || 'model requires a paid Devin entitlement' };
  }
  // ── REQUEST VALIDATOR REJECTION (4xx-class) ─────────────────────────────
  // `invalid_argument` / `failed_precondition` that matched no narrower verdict:
  // the upstream refused the REQUEST, not the account. Measured shapes are the
  // §7.1 image frame handled above (its text arm is skipped when the code is in
  // this set) and #273.1's "There is an issue with this request, please try a
  // different model." Both are permanent — the same bytes fail identically on
  // every retry — yet they used to surface as 503 (UPSTREAM_INTERNAL) or 502
  // (verbatim passthrough), telling clients to retry something that can never
  // succeed.
  //
  // PRECEDENCE (P2-5): this arm MUST run before the HTTP 401/403 auth shell
  // below. The measured delivery mode for a validator verdict is a 401/403
  // shell (the §7.1 trailer), so the status arm used to claim it first: the
  // proxy charged account health and started a re-login for a token that was
  // never dead. An explicit 4xx-class code is evidence about the BYTES and is
  // strictly more specific than the HTTP status; a real auth failure carries
  // no such code (or `permission_denied`/`unauthenticated`, which are not in
  // this set) and still reaches the UNAUTHORIZED arm unchanged.
  //
  // 400 invalid_request_error with the upstream message PLUS the capability hint
  // the caller needs to act on: the §7.1 rows are exactly the models whose
  // /v1/models row omits `supports_images`. Keep the hint free of the internal
  // leak markers messages.js generalizes on (devin_connect / session token /
  // failover / ...), or an Anthropic client would receive the generic text.
  // NO account penalty (the token is alive) and NO in-process retry
  // (isRetryable excludes the code).
  if (isRequestRejectionCode(code)) {
    const detail = body || `upstream rejected the request (${code})`;
    return {
      code: 'REQUEST_REJECTED',
      message: `${detail} [upstream rejected the request as invalid (${code}): the selected model `
        + 'may not support this input — e.g. an image on a model whose /v1/models row omits '
        + 'supports_images — or the message/tool shape is unsupported. Check supports_images in '
        + 'GET /v1/models and retry with a capable model or request shape]',
    };
  }
  // `code === 'unauthenticated'` is claimed here alongside 'permission_denied'. The
  // body-text arm already caught the common shape, but a bare gRPC status with a
  // body that does not repeat the word fell through to the verbatim passthrough at
  // the end — where it reached the account-fault fallthrough in
  // finalizeConnectAccount and evicted the account WITHOUT the re-login attempt
  // that a retired session token needs. Same verdict, but now via the arm that
  // knows what to do about it.
  if (status === 401 || status === 403 || /permission_denied|unauthenticated|invalid.*token/i.test(lc)
      || code === 'permission_denied' || code === 'unauthenticated') {
    return { code: 'UNAUTHORIZED', message: body || 'DEVIN_CONNECT: authentication failed' };
  }
  if (status === 429 || /rate.?limit|too many requests|resource_exhausted/i.test(lc) || code === 'resource_exhausted') {
    return { code: 'RATE_LIMITED', message: body || 'DEVIN_CONNECT: rate limited' };
  }
  return { code: code || 'UPSTREAM_ERROR', message: body || `DEVIN_CONNECT upstream error${status ? ` (HTTP ${status})` : ''}` };
}

// Transient codes worth an in-process retry: network blips + server "unavailable"
// only. Deliberately EXCLUDES:
//   - RATE_LIMITED: retrying the same token 2x before the pool-level cooldown
//     applies just triples the load on an already-throttled upstream. Let the
//     cooldown + cross-account failover handle it.
//   - internal: per this file's header the server returns `internal` for
//     PERMANENT client mistakes (short fingerprint, gzipped request body) — those
//     fail identically every retry, so retrying burns attempts for nothing.
//   - STREAM_TRUNCATED: the socket ended mid-stream without the mandatory
//     end-of-stream frame. Transport-level, indistinguishable from ECONNRESET in
//     cause, and a replay usually lands a complete answer. The stream path only
//     replays while nothing has been emitted, so a truncated turn that already
//     wrote bytes surfaces as an error instead.
const RETRYABLE_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'TIMEOUT', 'unavailable', 'CAPACITY', 'STREAM_TRUNCATED']);

/** True when an error should be retried (vs surfaced immediately). */
export function isRetryable(err) {
  if (!err) return false;
  // UPSTREAM_INTERNAL is deliberately NON-retryable (observed persistent 3/3 —
  // same-token retry just amplifies load, #35). Short-circuit BEFORE the 5xx
  // status branch, which would otherwise re-admit it when the fault arrives as
  // a genuine 5xx rather than in a 401/403 shell (audit F4).
  //
  // REQUEST_REJECTED joins it for the same reason and one more: the rejection is
  // about the BYTES, so a replay is guaranteed to fail identically. The 5xx
  // status branch would otherwise re-admit it when the upstream wraps the
  // validator verdict in a 5xx shell (measured delivery mode for transient
  // faults), turning a permanent 400-class verdict into a retry storm.
  if (err.code === 'UPSTREAM_INTERNAL' || err.code === 'REQUEST_REJECTED') return false;
  if (err.code && RETRYABLE_CODES.has(err.code)) return true;
  // HTTP 5xx (except 501) are transient; 4xx are not.
  if (typeof err.status === 'number') return err.status >= 500 && err.status !== 501;
  return false;
}

/** Map the upstream finish enum to the OpenAI finish_reason vocabulary.
 *
 * `StopReason` is no longer inferred from variant names: the binary descriptor
 * set (exa.codeium_common_pb.StopReason, 0–13, Q7 audit) pins every integer,
 * and the independent Go decoder in devin-2api agrees on the non-trivial rows
 * (INCOMPLETE / MAX_TOKENS / PARTIAL → length, FUNCTION_CALL → tool_use,
 * ERROR → error).
 *
 *   0 UNSPECIFIED               → stop
 *   1 INCOMPLETE                → length
 *   2 STOP_PATTERN              → stop   (live-anchored free-tier stop)
 *   3 MAX_TOKENS                → length
 *   4 MIN_LOG_PROB              → stop   (live-anchored paid stop)
 *   5 MAX_NEWLINES              → length
 *   6 EXIT_SCOPE                → stop
 *   7 NONFINITE_LOGIT_OR_PROB   → stop
 *   8 FIRST_NON_WHITESPACE_LINE → stop
 *   9 PARTIAL                   → length
 *  10 FUNCTION_CALL             → tool_calls
 *  11 CONTENT_FILTER            → content_filter
 *  12 NON_INSERTION             → stop
 *  13 ERROR                     → error
 *
 * 2 and 4 keep their live anchors: a free-tier completion returned 2, and a
 * paid teams capture (2026-07-27) returned 4 on complete answers — neither
 * contradicts the descriptor (a min-logprob cutoff is still a finished turn).
 * 13 has no OpenAI vocabulary entry; 'error' matches the existing special-agent
 * stream-failure convention, and resolveFinishReason never upgrades it.
 *
 * An operator can override any integer from a real capture via
 * DEVIN_CONNECT_STOP_REASON_MAP (e.g. "3=stop,13=stop"). Values outside 0–13
 * fall back to 'stop' so a completed stream is never surfaced as an error, and
 * are logged once per distinct value.
 */
const STOP_REASON_DEFAULT = Object.freeze({
  0: 'stop',            // STOP_REASON_UNSPECIFIED
  1: 'length',          // INCOMPLETE
  2: 'stop',            // STOP_PATTERN (live-anchored clean stop)
  3: 'length',          // MAX_TOKENS
  4: 'stop',            // MIN_LOG_PROB (live-anchored paid clean stop)
  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
});

// Unknown enum values are logged once each: a single mis-mapped turn should be
// visible in the log, but a server that repeats an out-of-range value must not
// turn one bug into a log flood. Capped so a hostile stream cannot grow the set.
const MAX_UNKNOWN_STOP_REASON_LOGS = 16;
const _unknownStopReasonsLogged = new Set();
function noteUnknownStopReason(n) {
  if (_unknownStopReasonsLogged.has(n) || _unknownStopReasonsLogged.size >= MAX_UNKNOWN_STOP_REASON_LOGS) return;
  _unknownStopReasonsLogged.add(n);
  log.warn(`devin-connect: unknown StopReason ${String(n)}; reporting finish_reason 'stop'`);
}

let _stopReasonMapCache = null;
let _stopReasonMapSrc = null;
// Which integers the OPERATOR explicitly pinned, as opposed to the ones that
// merely have a built-in default. resolveFinishReason needs this distinction: the
// default table already contains 0..6, so testing membership there would treat
// every value as "operator-calibrated" the moment ANY override is set.
let _stopReasonOverridesCache = null;
function stopReasonMapAndOverrides(env = process.env) {
  const raw = String(env.DEVIN_CONNECT_STOP_REASON_MAP || '').trim();
  if (raw === _stopReasonMapSrc && _stopReasonMapCache) {
    return { map: _stopReasonMapCache, overrides: _stopReasonOverridesCache };
  }
  _stopReasonMapSrc = raw;
  const map = { ...STOP_REASON_DEFAULT };
  const overrides = new Set();
  const allowed = new Set(['stop', 'length', 'tool_calls', 'content_filter']);
  for (const pair of raw.split(',')) {
    const [k, v] = pair.split('=').map((s) => s.trim());
    const n = Number.parseInt(k, 10);
    if (Number.isInteger(n) && n >= 0 && allowed.has(v)) { map[n] = v; overrides.add(n); }
  }
  _stopReasonMapCache = map;
  _stopReasonOverridesCache = overrides;
  return { map, overrides };
}

function stopReasonMap(env = process.env) {
  return stopReasonMapAndOverrides(env).map;
}

/**
 * Normalize the upstream usage counters into the OpenAI-shaped object every
 * protocol route reads. Exported so it can be tested against the real
 * implementation rather than a copy — the earlier test mirrored this arithmetic
 * inline, which meant degrading the production path did not fail it.
 *
 * Connect semantics (O2, evidence/ClientSandboxes.md §5.3):
 *   prompt_tokens  = fresh + cache_read + cache_write  (the input the model processed)
 *   total_tokens   = prompt + completion               (identity holds by construction)
 *   prompt_tokens_details.cached_tokens = cache_read   (a SUBSET of prompt_tokens)
 *   cache_creation_input_tokens        = cache_write   (Anthropic sibling for relays)
 *   fresh_input_tokens                 = fresh         (explicit bucket; see below)
 *
 * Why cache_write is INSIDE prompt_tokens here while the Cascade path keeps it out
 * (#118): on a connect cache-write turn the upstream reports the prefix it just
 * processed on tag 4 and only the new tail on tag 2. Measured on claude-sonnet-4.6:
 * round-1 write=14361, round-2 read=14356 + fresh=5 — i.e. the write IS the same
 * input, not a generation-side extra. Excluding it made omp render "3 in" for an
 * 11.5k prompt on 2/7 turns, and those two turns were exactly the prefix-miss
 * (cache-write) requests. The Cascade split stays as #118 chose it, so consumers
 * must NOT re-derive fresh as `prompt - cached` on this shape: `fresh_input_tokens`
 * carries it explicitly (auth.js fullBillableTokens, messages.js, dashboard stats).
 *
 * `prompt_tokens_details` is emitted even when the upstream reported no cache read
 * (tag 5 is absent on a write turn). Omitting it made the breakdown vanish on
 * exactly the responses that needed explaining.
 *
 * Returns null for a missing usage block so callers can pass it straight through.
 */
export function normalizeConnectUsage(lastUsage) {
  if (!lastUsage) return null;
  const fresh = lastUsage.prompt || 0;
  const cacheRead = lastUsage.cache_read_tokens || 0;
  const cacheWrite = lastUsage.cache_write_tokens || 0;
  const completion = lastUsage.completion || 0;
  // cache_write is input on this path, so it is part of prompt_tokens and must not
  // be added to the total a second time. WINDSURFAPI_STRICT_USAGE_TOTAL is therefore
  // a no-op here — the OpenAI identity already holds (asserted in
  // test/strict-usage-total.test.js so a future re-split has to revisit it).
  const promptTokens = fresh + cacheRead + cacheWrite;
  return {
    prompt_tokens: promptTokens,
    completion_tokens: completion,
    total_tokens: promptTokens + completion,
    prompt_tokens_details: { cached_tokens: cacheRead },
    fresh_input_tokens: fresh,
    ...(lastUsage.cache_write_tokens != null
      ? { cache_creation_input_tokens: cacheWrite }
      : {}),
  };
}

export function mapFinishReason(finish, env = process.env) {
  if (finish == null) return null;
  const n = typeof finish === 'bigint' ? Number(finish) : finish;
  const map = stopReasonMap(env);
  // Unknown values → 'stop': a completed stream must never read as an error.
  // hasOwn keeps a non-numeric finish from resolving through Object.prototype.
  if (Object.hasOwn(map, n)) return map[n];
  noteUnknownStopReason(n);
  return 'stop';
}

/**
 * Apply the caller's output cap to a terminal finish reason.
 *
 * LIVE FINDING (evidence/ToolMatrixLive.md, flag 2): `gemini-3-6-flash-medium`
 * max_tokens does not bound thinking. Probes returned completion_tokens 105
 * under max_tokens:64 and 271 under 128 while the visible answer was a short
 * string — the upstream counts thinking inside completion_tokens but spends it
 * OUTSIDE the cap. The old check here used strict equality (`completion === cap`),
 * so every overrun sailed past it and the turn reported a clean 'stop': a budget
 * overrun indistinguishable from a complete answer. GPT overran the same way at
 * 16 (32 > 16), so the rule is model-agnostic, not Gemini-specific.
 *
 * `>=` is the honest test: the caller asked for at most `cap` output tokens and
 * the upstream produced at least that many. The equality half keeps the older,
 * narrower truncation signal.
 *
 * Why this reports instead of enforces: the only EXACT token count is the
 * upstream's, and it arrives on the terminal frame after every delta has been
 * delivered. The proxy has no tokenizer (chars/4 is an estimate), so cutting the
 * stream at the cap would drop delivered content mid-answer and could label a
 * complete answer truncated — the failure mode this same comment has warned
 * about since the paid stop-reason calibration. Reporting 'length' leaves the
 * answer intact and tells the client its budget was exceeded.
 *
 * Only a clean 'stop' can be upgraded: tool_calls / content_filter / error are
 * stronger statements about what the turn did. An explicit operator override in
 * resolveFinishReason still wins over this inference.
 *
 * @param {string} finishReason  already-mapped OpenAI finish_reason
 * @param {object|null} usage    normalized usage ({completion_tokens})
 * @param {number|null} maxTokens  output cap the caller requested, if any
 * @returns {string} the reason to report
 */
export function applyOutputCap(finishReason, usage, maxTokens) {
  if (finishReason !== 'stop') return finishReason;
  const cap = Number(maxTokens);
  const completion = Number(usage?.completion_tokens);
  if (Number.isFinite(cap) && cap > 0 && Number.isFinite(completion) && completion >= cap) {
    return 'length';
  }
  return finishReason;
}

/**
 * Resolve the OpenAI finish_reason for a completed connect turn.
 *
 * The descriptor now pins the enum (see STOP_REASON_DEFAULT), so MAX_TOKENS (3)
 * reports 'length' without inference. The usage check stays as a second line of
 * defense for the values the descriptor calls a clean stop (2/4): a turn that
 * reached or overran the caller's output budget is still truncated, and that
 * signal does not depend on the enum at all — it is the same test an OpenAI
 * client would apply itself. See applyOutputCap for why the comparison is `>=`
 * rather than equality (thinking is counted in completion_tokens but not bounded
 * by the cap on every tier).
 *
 * An explicit DEVIN_CONNECT_STOP_REASON_MAP override always wins: an operator who
 * HAS captured the real integers is a better authority than this inference.
 *
 * @param {number|bigint|null} finish  raw upstream enum (proto #5)
 * @param {object|null} usage         normalized usage ({completion_tokens})
 * @param {number|null} maxTokens     output cap the caller requested, if any
 */
export function resolveFinishReason(finish, usage, maxTokens, env = process.env) {
  const mapped = mapFinishReason(finish, env);
  // A calibrated override for THIS value takes precedence over the inference.
  //
  // It must consult the keys the operator actually PINNED, not the built-in table:
  // the defaults already cover 0..6, so an `Object.hasOwn(map, n)` test made any
  // single unrelated override (e.g. "7=length") read as "value 2 is calibrated" and
  // silently disabled the usage-based truncation inference for every real turn.
  const { overrides } = stopReasonMapAndOverrides(env);
  const key = typeof finish === 'bigint' ? Number(finish) : finish;
  if (finish != null && overrides.has(key)) return mapped;
  return applyOutputCap(mapped, usage, maxTokens);
}


/**
 * Stream a chat completion over DEVIN_CONNECT.
 *
 * Yields structured events as they arrive:
 *   { type: 'content',   text }   — user-visible answer delta (proto #3)
 *   { type: 'reasoning', text }   — thinking delta            (proto #9)
 *   { type: 'finish', reason, usage } — emitted once at end-of-stream
 *
 * The generator resolves when the upstream sends its end-of-stream trailer; a
 * non-empty trailer error body is surfaced as a thrown Error so callers don't
 * treat a failed stream as empty.
 *
 * @param {object} params  see buildGetChatMessageRequest, plus:
 * @param {AbortSignal} [params.signal]  abort the in-flight request
 * @param {number} [params.timeoutMs]  socket IDLE timeout (no-activity); env
 *   DEVIN_CONNECT_IDLE_TIMEOUT_MS, default 120000.
 * @param {number} [params.deadlineMs]  ABSOLUTE wall-clock cap from request
 *   start; env DEVIN_CONNECT_TIMEOUT_MS, default 600000. Guards a hung-but-
 *   trickling upstream that the idle timer can never catch.
 * @param {object} [params.env]
 * @returns {AsyncGenerator<{type:string, text?:string, reason?:string, usage?:object}>}
 */
export async function* streamChat({
  messages, model, sessionId, completion, tools,
  token, signal, timeoutMs, deadlineMs, host, env = process.env, nativeToolCall = false, traceId = null,
  deviceSeed, sessionModelConfig, continuityTrail, toolChoice, parallelToolCalls,
} = {}) {
  if (signal?.aborted) {
    const err = new Error('The operation was aborted');
    err.name = 'AbortError';
    err.code = 'ABORT_ERR';
    throw err;
  }
  // Idle timeout: socket inactivity. Absolute deadline: total wall-clock from
  // request start — this is the one that catches a stream that keeps dribbling
  // a byte at a time (defeating the idle timer) but never actually completes.
  const idleTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
    ? timeoutMs : (Number(env.DEVIN_CONNECT_IDLE_TIMEOUT_MS) || 120000);
  const absoluteDeadlineMs = Number.isFinite(deadlineMs) && deadlineMs > 0
    ? deadlineMs : (Number(env.DEVIN_CONNECT_TIMEOUT_MS) || 600000);
  const sessionToken = token || getConnectToken(env);
  if (!sessionToken) {
    throw Object.assign(new Error('DEVIN_CONNECT: no session token configured'), { code: 'NO_TOKEN' });
  }
  // Billing tag map (opt-in, default null). Parsed once per request, off the
  // hot per-frame path.
  const billingTags = parseBillingTagMap(env);
  // Calibration: when set, dump every metadata varint subfield once per stream
  // (the terminal frame) so unknown tags (cache tokens / billing) are visible.
  const dumpMeta = env.DEVIN_CONNECT_DEBUG_META === '1';
  // Optional: surface actual_model_uid (concrete model behind a router) when the
  // operator has pinned its tag from a capture. Off → never decoded.
  const actualModelTag = Number.parseInt(env.DEVIN_CONNECT_ACTUAL_MODEL_TAG || '', 10) || null;
  // Optional: native tool-call decode (repeated ChatToolCall) when the response
  // tags are calibrated. Off → tool calls come from prompt emulation as today.
  const toolCallTags = parseToolCallTagMap(env, { useDefault: nativeToolCall });
  // Optional: thinking-signature decode (delta_signature/_type/thinking_id) when
  // DEVIN_CONNECT_SIGNATURE_TAG is pinned from a paid/thinking capture. Off →
  // never decoded, messages.js keeps its empty-string signature placeholder.
  const signatureTags = parseSignatureTagMap(env);
  // Request-side tool names, for the native-decode name fallback (response-side
  // ChatToolCall carries no name — see decodeToolCalls header). Only collected when
  // native decode is actually on, so it costs nothing on the default path. The
  // tools array is OpenAI-shaped ({type:'function', function:{name}}); fall back to
  // a bare {name} too.
  const toolNames = (toolCallTags && Array.isArray(tools))
    ? tools.map((t) => t?.function?.name || t?.name).filter(Boolean)
    : null;

  // Optional short-lived credential (#21). Awaited before the proto is built
  // because it travels INSIDE the message, not as a header. mintUserJwt never
  // throws and resolves null on every failure path, so enabling the switch cannot
  // turn an upstream JWT problem into a failed user request — it degrades to the
  // historical no-JWT wire. Minted against the DEFAULT host on purpose: the
  // per-account host override below is a default-off RE aid, and pointing the auth
  // service at an overridden host would mint against a different origin than the
  // one the .protos describe.
  const userJwt = isUserJwtEnabled(env)
    ? await mintUserJwt(sessionToken, { host: HOST, signal, deviceSeed })
    : undefined;
  // Remote (http/https) image_url blocks are fetched HERE, before the synchronous
  // wire builder, because extractInlineImages cannot fetch. Gated on the image
  // field being emitted at all: DEVIN_CONNECT_IMAGE_TAG=0 means images are off,
  // so spending network time on them would be pure waste. The returned array is
  // new only when something changed; the caller's messages are never mutated
  // (they are re-passed on retry/failover).
  const imageTag = getImageFieldTag(env);
  const wireMessages = imageTag
    ? await inlineRemoteImages(messages, { signal })
    : messages;
  const proto = buildGetChatMessageRequest({ token: sessionToken, messages: wireMessages, model, sessionId, completion, tools, nativeToolCall, deviceSeed, userJwt, env, sessionModelConfig, continuityTrail, toolChoice, parallelToolCalls });
  // Request envelope is sent UNCOMPRESSED (flag 0). The live calibration showed
  // the server rejects a gzipped request frame with an opaque "internal" error;
  // it still streams gzipped frames back, which the parser handles.
  const framed = wrapEnvelope(proto, { compress: false });

  // Wire capture (gated): dump the exact request protobuf (pre-envelope `proto`,
  // the clean bytes) for offline RE. No-op unless DEVIN_CONNECT_WIRE_DUMP=1.
  dumpWire('req', proto, { model, traceId, note: `model=${model} sessionId=${sessionId || ''} tools=${Array.isArray(tools) ? tools.length : 0} nativeToolCall=${nativeToolCall}` });

  // AUTH (critical): the header token is the session token doubled, dash-joined.
  const authHeader = `Basic ${sessionToken}-${sessionToken}`;

  // Host resolution. REVERTED (2026-07-08): the DEVIN_CONNECT_ACCOUNT_HOST
  // override was built on the hypothesis that a teams/self-serve token must send
  // GetChatMessage to its own apiServerUrl. Live capture DISPROVES this — the real
  // teams CLI sends GetChatMessage to server.codeium.com (captures/req-00N-
  // server.codeium.com-CHAT.bin, session 9501aa2c), the SAME host where our
  // GetCliModelConfigs / GetUserStatus already succeed. The 401 was never a host
  // problem. The override is kept behind its (default-off) flag purely so an
  // operator can still force a host during future RE, but it is NOT a fix and must
  // not be enabled in production — a wrong host will break chat. Default: HOST.
  let effectiveHost = HOST;
  if (host && String(env.DEVIN_CONNECT_ACCOUNT_HOST || '') === '1') {
    // Reject this Connect attempt explicitly; never send its token to an
    // unapproved origin or silently change account/backend policy here.
    effectiveHost = validatedAccountConnectHost(host);
  }

  // Bounded frame queue (GFReliability). `res.on('data')` decodes frames far
  // faster than a slow consumer drains them (a client applying backpressure
  // suspends the generator at `yield`), and this array used to be unbounded —
  // one stalled client could pin a whole upstream stream's decoded output in
  // memory. Cap by frames AND bytes: pause the socket at half the cap so TCP
  // carries the backpressure upstream, and fail the stream with QUEUE_OVERFLOW
  // if one chunk still crosses the cap (a single data event can carry thousands
  // of tiny frames, and pausing cannot interrupt the synchronous loop that is
  // already running). Below the pause threshold nothing changes — same events,
  // same order, no pause/resume.
  const maxQueueFrames = positiveIntEnv(env.DEVIN_CONNECT_MAX_QUEUE_FRAMES, 4096);
  const maxQueueBytes = positiveIntEnv(env.DEVIN_CONNECT_MAX_QUEUE_BYTES, 8 * 1024 * 1024);
  const pauseQueueFrames = Math.max(1, Math.ceil(maxQueueFrames / 2));
  const pauseQueueBytes = Math.max(1, Math.ceil(maxQueueBytes / 2));
  // Resume well below the pause point: resuming at the threshold itself would
  // let the producer refill to it immediately, cycling pause/resume once per
  // event. Half the pause point lets a real batch through per cycle.
  const resumeQueueFrames = Math.max(1, Math.floor(pauseQueueFrames / 2));
  const resumeQueueBytes = Math.max(1, Math.floor(pauseQueueBytes / 2));
  const queue = [];
  let queueBytes = 0;
  let queuePaused = false;
  let queueOverflowed = false;
  // Assigned by the response handler (it needs `res`); the consumer loop calls
  // it after every shift. A no-op until the response arrives.
  let noteQueueConsumed = () => {};
  let done = false;
  let streamError = null;
  let wake = null;
  // Did the upstream send its mandatory end-of-stream frame? Connect-RPC always
  // terminates a stream with one (carrying `{}` on success or an error trailer), so
  // a socket that just ends without it delivered a PARTIAL answer. Before this flag
  // that case was indistinguishable from a clean completion: the generator returned
  // normally with reason=null, mapFinishReason defaulted null→'stop', and the half
  // answer was reported as a finished turn — on /v1/responses closing the turn as
  // `response.completed` and entering the response store as the next turn's context.
  let sawEndOfStream = false;
  // Did ANY frame carry the terminal stopReason (#5)? The end-of-stream frame
  // proves the RPC completed; it says nothing about whether the TURN completed.
  // Live evidence (Q1 audit §3 S01, docs/upstream-protocol.md:106): every provider
  // observed so far (STOP_PATTERN / MIN_LOG_PROB / UNSPECIFIED) reports a value on
  // a clean finish, and "clean EOF with no stopReason IS truncation, not a normal
  // end" — their decoder raises a stream error instead of synthesizing end_turn.
  // Without this flag the two shapes were indistinguishable: lastFinish stayed
  // null, and null defaults to 'stop' at every consumer, so a truncated turn was
  // reported as a completed one.
  let sawStopReason = false;
  // Was any answer payload delivered? A stream that ends with nothing on it and no
  // stopReason is the EMPTY-turn shape (the adapter's empty/rescue retry owns it),
  // not a truncated answer — it must keep its null reason so that machinery is
  // untouched.
  let sawPayload = false;
  let lastFinish = null;
  let lastUsage = null;
  let lastBilling = null;
  let lastActualModel = null;
  let lastSignature = null;
  const nativeToolCalls = [];
  // wire-01: StringDecoder holds an incomplete trailing multi-byte UTF-8 sequence
  // between frames, so a Chinese char / emoji split across two frames decodes
  // whole instead of becoming U+FFFD on each half. One decoder per text stream.
  const contentDecoder = new StringDecoder('utf8');
  const reasoningDecoder = new StringDecoder('utf8');
  const pump = () => { if (wake) { const w = wake; wake = null; w(); } };

  const req = requestImpl({
    hostname: effectiveHost,
    port: 443,
    path: PATH,
    method: 'POST',
    headers: connectHeaders({
      authorization: authHeader,
      'Content-Length': framed.length,
      Accept: '*/*',
    }),
    signal,
  }, (res) => {
    // Non-200: the body is an error payload (JSON/text), NOT connect frames.
    // Buffer it and classify, so callers get a stable code (MODEL_BLOCKED for
    // free-tier /upgrade, UNAUTHORIZED, RATE_LIMITED) instead of an opaque
    // frame-parse failure from feeding an error body to StreamingFrameParser.
    if (res.statusCode && res.statusCode !== 200) {
      // Bound the buffer. An error body is a diagnostic, not a payload: the
      // only thing downstream does with it is classifyUpstreamError, which
      // reads a code, a message and an optional reset window. Reading it
      // unbounded is an availability bug — see createBoundedBodyAccumulator
      // for the crash it causes and why the crossing chunk is sliced.
      const buffer = createBoundedBodyAccumulator();
      res.on('data', buffer.onData);
      res.on('end', () => {
        const { raw, truncated } = buffer.finish();
        const body = raw.toString('utf8') + (truncated ? ERROR_BODY_TRUNCATION_MARKER : '');
        if (truncated) {
          log.warn(`[devin-connect] upstream ${res.statusCode} error body exceeded ${MAX_ERROR_BODY_BYTES} bytes; truncated for classification`);
        }
        // Preserve the upstream Connect-RPC error code (audit F2): the trailer
        // path forwards parsed.error.code, but this non-200 path historically
        // dropped it (code=null), so an `unavailable`/`resource_exhausted`
        // delivered as a non-200 HTTP error lost the signal that classifies it
        // as transient. Best-effort JSON parse; falls back to body+status.
        let upstreamCode = null;
        try { upstreamCode = JSON.parse(body)?.error?.code || null; } catch { /* text body */ }
        // Preserve resetMs (audit F3): classifyUpstreamError parses an explicit
        // reset window (e.g. "Resets in: 3h0m0s" → resetMs) but this path used to
        // destructure only { code, message }, dropping it — so the handler cooled
        // the account for a generic burst window instead of the real 3h. Forward
        // it when present so finalizeConnectAccount can honor the true duration.
        const classified = classifyUpstreamError(body, upstreamCode, res.statusCode);
        streamError = Object.assign(new Error(classified.message), {
          code: classified.code,
          status: res.statusCode,
          ...(classified.resetMs != null ? { resetMs: classified.resetMs } : {}),
        });
        done = true;
        pump();
      });
      res.on('error', (err) => { streamError = err; done = true; pump(); });
      return;
    }
    const parser = new StreamingFrameParser();
    // Queue accounting. Text dominates the retained memory (UTF-16 chars) and
    // hex dumps are one char per wire byte; 96 bytes covers the event object
    // itself. Deliberately cheap — it runs once per pushed event, not per byte,
    // and the caps are safety bounds rather than an exact allocator.
    const eventBytes = (ev) => {
      let n = 96;
      if (typeof ev.text === 'string') n += ev.text.length * 2;
      // Reasoning signatures are opaque payloads of the same order as content,
      // but they ride in `reasoning_signature`, not `text`. Without this the
      // byte cap would charge them a flat 96 bytes and a thinking-heavy stream
      // could exceed the bound by maxQueueFrames × signature size.
      if (typeof ev.reasoning_signature === 'string') n += ev.reasoning_signature.length * 2;
      if (typeof ev.hex === 'string') n += ev.hex.length;
      return n;
    };
    const pushEvent = (ev) => {
      if (queueOverflowed) return;
      queue.push(ev);
      queueBytes += eventBytes(ev);
      if (queue.length > maxQueueFrames || queueBytes > maxQueueBytes) {
        // Hard cap. Drop what is buffered: the consumer has not seen these
        // events yet and the stream is failing anyway — retaining them would
        // defeat the bound this exists to enforce.
        queueOverflowed = true;
        queue.length = 0;
        queueBytes = 0;
        streamError = Object.assign(
          new Error(`DEVIN_CONNECT: frame queue exceeded ${maxQueueFrames} frames / ${maxQueueBytes} bytes (consumer too slow)`),
          { code: 'QUEUE_OVERFLOW' },
        );
        done = true;
        req.destroy();
        pump();
        return;
      }
      // High-water mark: stop reading the socket and let TCP carry the
      // backpressure upstream. A transport without pause() still gets the hard
      // cap above, so the bound holds either way.
      if (!queuePaused && typeof res.pause === 'function'
          && (queue.length >= pauseQueueFrames || queueBytes >= pauseQueueBytes)) {
        queuePaused = true;
        res.pause();
      }
    };
    noteQueueConsumed = (ev) => {
      queueBytes = Math.max(0, queueBytes - eventBytes(ev));
      if (queuePaused && !queueOverflowed
          && queue.length <= resumeQueueFrames && queueBytes <= resumeQueueBytes) {
        queuePaused = false;
        if (typeof res.resume === 'function') res.resume();
      }
    };
    const _wireCapture = String(process.env.DEVIN_CONNECT_WIRE_DUMP || '') === '1'
      || (String(process.env.WINDSURFAPI_TRACE || '') === '1' && !!traceId);
    const _wireResChunks = _wireCapture ? [] : null;
    res.on('data', (chunk) => {
      if (_wireResChunks) _wireResChunks.push(chunk);
      parser.push(chunk);
      let frames;
      try { frames = parser.drain(); }
      catch (err) { streamError = err; done = true; req.destroy(); pump(); return; }
      for (const frame of frames) {
        if (queueOverflowed) break;
        if (frame.isEndStream) {
          sawEndOfStream = true;
          // Trailer is JSON: {} on success, {"error":{...}} on failure.
          const text = frame.payload.toString('utf8').trim();
          // Calibration (DEBUG-gated, default OFF): surface the raw trailer bytes
          // so a probe can check whether usage/billing ever rides the trailer
          // rather than a frame. Pure additive; zero effect on production.
          if (env.DEVIN_CONNECT_DUMP_RAW === '1') {
            pushEvent({ type: 'raw-frame', endStream: true, hex: frame.payload.toString('hex'), text });
          }
          if (text && text !== '{}') {
            try {
              const parsed = JSON.parse(text);
              if (parsed?.error) {
                const classified = classifyUpstreamError(
                  parsed.error.message || text, parsed.error.code || null, null);
                streamError = Object.assign(new Error(classified.message), {
                  code: classified.code,
                  upstream: parsed.error,
                  ...(classified.resetMs != null ? { resetMs: classified.resetMs } : {}),
                });
              }
            } catch { /* non-JSON trailer — leave as success */ }
          }
          if (_wireResChunks) dumpWire('res', Buffer.concat(_wireResChunks), { model, traceId, note: `raw connect frames (gzip-as-sent), trailer=${text.slice(0, 200)}` });
          done = true;
          pump();
          return;
        }
        const { contentBytes, reasoningBytes, finish, usage, billing, metaDump, frameDump, subDump, actualModel, toolCalls, signature } = decodeFrame(frame.payload, { billingTags, dumpMeta, actualModelTag, toolCallTags, signatureTags, toolNames });
        // wire-01: decode across frame boundaries so split multi-byte chars survive.
        const content = contentBytes ? contentDecoder.write(contentBytes) : '';
        const reasoning = reasoningBytes ? reasoningDecoder.write(reasoningBytes) : '';
        // Calibration (DEBUG-gated, default OFF): emit the raw frame payload hex
        // so a probe can re-decode it WIDE — the production frameDump/metaDump
        // only collect wireType 0/2 and miss wt1(double)/wt5(float), which is
        // exactly where billing cost fields (likely doubles) would hide. Pure
        // additive; only under DEVIN_CONNECT_DUMP_RAW.
        if (env.DEVIN_CONNECT_DUMP_RAW === '1') {
          pushEvent({ type: 'raw-frame', endStream: false, hex: frame.payload.toString('hex') });
        }
        if (frameDump) log.info(`DEVIN_CONNECT frame dump (top-level tag=value): ${JSON.stringify(frameDump)}`);
        if (metaDump) log.info(`DEVIN_CONNECT meta dump (tag=value varints): ${JSON.stringify(metaDump)}`);
        if (subDump) log.info(`DEVIN_CONNECT sub-message dump (top-tag → inner tag=value): ${JSON.stringify(subDump)}`);
        // When dumping, also surface the raw dumps as a structured event so a
        // calibration consumer can aggregate tags without scraping logs. Pure
        // additive — only emitted under DEVIN_CONNECT_DEBUG_META.
        if (frameDump || metaDump || subDump) { pushEvent({ type: 'frame-dump', frameDump: frameDump || null, metaDump: metaDump || null, subDump: subDump || null }); }
        if (actualModel) lastActualModel = actualModel;
        if (signature) {
          // delta_signature is an INCREMENT (like Anthropic signature_delta).
          // Accumulate the opaque payload for the terminal finish event AND emit
          // a per-frame delta so a streaming consumer can attach it to the open
          // thinking block. Field name `reasoning_signature` aligns with what
          // messages.js round-trips (handlers/messages.js:658). Additive event
          // type → current consumers (which switch on reasoning/content/finish)
          // ignore it; zero regression when the tag is uncalibrated (signature
          // is null and this whole branch is dead).
          lastSignature = {
            text: (lastSignature ? lastSignature.text : '') + signature.text,
            ...(signature.signatureType != null ? { signatureType: signature.signatureType } : {}),
            ...(signature.thinkingId != null ? { thinkingId: signature.thinkingId } : {}),
          };
          pushEvent({
            type: 'signature',
            reasoning_signature: signature.text,
            ...(signature.signatureType != null ? { signatureType: signature.signatureType } : {}),
            ...(signature.thinkingId != null ? { thinkingId: signature.thinkingId } : {}),
          });
        }
        if (reasoning) { pushEvent({ type: 'reasoning', text: reasoning }); }
        if (content) { pushEvent({ type: 'content', text: content }); }
        if (finish != null) { lastFinish = finish; sawStopReason = true; }
        if (content || reasoning || signature) sawPayload = true;
        if (usage) lastUsage = usage;
        if (billing) lastBilling = { ...lastBilling, ...billing };
        if (toolCalls) {
          for (const tc of toolCalls) mergeToolCallFragment(nativeToolCalls, tc);
          sawPayload = true;
        }
        if (reasoning || content || frameDump || metaDump || signature) pump();
      }
    });
    res.on('end', () => { done = true; pump(); });
    res.on('error', (err) => { streamError = err; done = true; pump(); });
  });

  req.on('error', (err) => {
    if (!streamError) streamError = err;
    done = true;
    pump();
  });
  req.setTimeout(idleTimeoutMs, () => {
    req.destroy();
    if (!streamError) streamError = Object.assign(new Error('DEVIN_CONNECT: idle timeout (no data)'), { code: 'TIMEOUT' });
    done = true;
    pump();
  });
  // Absolute wall-clock deadline: a hung upstream that trickles bytes keeps
  // resetting the idle timer above and would otherwise stream forever. This
  // fires regardless of activity and is the real backstop against a stuck
  // request pinning an account's _inflight slot.
  const deadlineTimer = setTimeout(() => {
    req.destroy();
    // DEADLINE_EXCEEDED is deliberately DISTINCT from the idle 'TIMEOUT' above.
    // Idle timeout (120s of silence) is often a transient upstream stall worth a
    // same-token replay, so 'TIMEOUT' is in RETRYABLE_CODES. But this absolute
    // wall-clock deadline means the upstream has been hung for the full window
    // (600s) — a replay just runs another full idle+deadline cycle against the
    // SAME stuck upstream (≈2× wall-clock, ~1200s to finally error), almost
    // always failing again. Give it its own non-retryable code so the stream
    // replay gate (chat.js isConnectRetryable) surfaces it immediately instead
    // of doubling the user's wait. (external audit 2026-07-12, flaw 1)
    if (!streamError) streamError = Object.assign(new Error(`DEVIN_CONNECT: absolute deadline ${absoluteDeadlineMs}ms exceeded`), { code: 'DEADLINE_EXCEEDED' });
    done = true;
    pump();
  }, absoluteDeadlineMs);
  // The deadline timer stays OUT of the loop's ref set by default: it must never hold the
  // process open on its own (a caller that walks away from the iterator without closing it
  // would otherwise pin shutdown for up to absoluteDeadlineMs). The consumer wait below
  // re-refs it for exactly the window in which it is the only thing that can make progress.
  if (typeof deadlineTimer.unref === 'function') deadlineTimer.unref();

  req.write(framed);
  req.end();

  // Consumer loop: drain the queue, awaiting more data until the stream ends.
  try {
    while (true) {
      if (queue.length) {
        const ev = queue.shift();
        noteQueueConsumed(ev);
        yield ev;
        continue;
      }
      if (streamError) throw streamError;
      if (done) {
        // wire-01: flush any trailing bytes the decoders were holding (an
        // incomplete multi-byte sequence at the very end of the stream). Normally
        // empty — the upstream doesn't split the final char — but flushing keeps
        // the invariant that all received bytes are emitted.
        const tailContent = contentDecoder.end();
        const tailReasoning = reasoningDecoder.end();
        if (tailReasoning) { sawPayload = true; yield { type: 'reasoning', text: tailReasoning }; }
        if (tailContent) { sawPayload = true; yield { type: 'content', text: tailContent }; }
        // The socket ended without the mandatory end-of-stream frame: the answer is
        // TRUNCATED, not finished. Throwing here (rather than yielding a finish
        // event) is what keeps a partial answer out of every "completed turn"
        // pathway — the /v1/responses translator only commits to the response store
        // on a clean finish, the session-continuity pair-chain only commits on 'ok',
        // and the stream path can still replay while nothing was emitted. Raised
        // AFTER the tail flush so a caller that already streamed bytes to its client
        // has delivered everything the upstream actually sent.
        if (!sawEndOfStream) {
          throw Object.assign(
            new Error('DEVIN_CONNECT: upstream stream ended without an end-of-stream frame (truncated response)'),
            { code: 'STREAM_TRUNCATED' },
          );
        }
        // One terminal event carrying finish_reason + usage for the caller to
        // close out an OpenAI-shaped response.
        const finishUsage = normalizeConnectUsage(lastUsage);
        // G3 (Q1 audit §7): an end-of-stream frame with an ANSWER on it but NO
        // stopReason is a truncated turn, not a finished one (S01
        // docs/upstream-protocol.md:106: "缺 stopReason 的干净 EOF = 截断不是正常结束",
        // measured on a real incident where a coding agent marked the task complete
        // on a half answer). `lastFinish === null` therefore cannot be allowed to
        // reach a consumer as "no reason yet", because every consumer defaults a
        // null reason to 'stop' (devin-connect-openai.js: `if (ev.reason)`).
        //
        // Reported as the finish_reason 'error' — the vocabulary entry the enum's
        // own ERROR value (13) already uses — instead of throwing: the bytes are
        // already on the client's wire by this point, and the missing end-of-stream
        // frame above is the case that genuinely cannot be trusted at all. A stream
        // that delivered NOTHING and named no reason keeps its null reason: that is
        // the empty-turn shape the adapter's empty/rescue retry exists for.
        const unterminated = !sawStopReason && sawPayload;
        const terminalReason = unterminated
          ? 'error'
          : resolveFinishReason(lastFinish, finishUsage, completion?.maxTokens ?? WIRE_DEFAULT_MAX_TOKENS, env);
        yield {
          type: 'finish',
          // Truncation is inferred from completion_tokens hitting the cap that was
          // actually ENCODED, NOT from the un-calibrated enum — see resolveFinishReason.
          //
          // It must be the effective cap, not `completion?.maxTokens`: since the #2/#3
          // tag fix the wire carries WIRE_DEFAULT_MAX_TOKENS whenever the caller named
          // no cap, so reading the caller's (absent) value here would leave every
          // defaulted call's truncation undetectable — the stream would stop at the
          // wire cap and still report a clean 'stop'.
          reason: terminalReason,
          // Additive, and only for the shape above: a consumer that wants to tell
          // "the model errored" from "the upstream never said how the turn ended"
          // does not have to infer it from a missing enum.
          ...(unterminated ? { unterminated: true } : {}),
          // Normalized at the birth point, so all four protocol routes read one
          // object: prompt_tokens is the FULL input (fresh + cache_read +
          // cache_write), cached_tokens is its SUBSET detail, and total_tokens is
          // prompt + completion. Without this the connect path reported the
          // upstream's raw fresh-input figure as prompt_tokens, so on a cache-hit
          // turn it emitted cached_tokens=1765 alongside prompt_tokens=3 — a
          // superset field larger than the field it is a subset of — and on a
          // cache-WRITE turn it showed "3 in" for an 11.5k prompt (O2,
          // evidence/ClientSandboxes.md §5.3). Every relay in front of this proxy
          // (one-api / new-api and friends) meters from exactly these numbers.
          // Live-reproduced.
          usage: finishUsage,
          // Billing detail (credit/acu cost) only present when an operator has
          // calibrated DEVIN_CONNECT_BILLING_TAGS against a paid token. Null on
          // free tier / un-configured deployments — zero behavioral change.
          billing: lastBilling,
          // Concrete model behind a router selector, when DEVIN_CONNECT_ACTUAL_MODEL_TAG
          // is pinned. Null otherwise — callers keep echoing the requested name.
          actualModel: lastActualModel,
          // Native tool calls (repeated ChatToolCall) when DEVIN_CONNECT_TOOL_CALL_TAGS
          // is calibrated. Empty otherwise — prompt emulation owns tool calls today.
          toolCalls: nativeToolCalls.length ? nativeToolCalls : null,
          // Full thinking signature (concatenated delta_signature) when
          // DEVIN_CONNECT_SIGNATURE_TAG is calibrated. Null on free tier /
          // un-configured deployments — messages.js then keeps its empty-string
          // signature_delta placeholder (forward-compatible round-trip).
          reasoning_signature: lastSignature ? lastSignature.text : null,
          signature: lastSignature,
        };
        return;
      }
      // While this wait is pending, the absolute deadline is the ONLY handle that can make
      // progress (the fake/hung upstream delivers no data, no error and no socket activity),
      // so it must be ref'd for the duration. Node 20/22 cancel a pending await the moment the
      // event loop drains — "Promise resolution is still pending but the event loop has
      // already resolved" — which turned a hung upstream into a cancelled test and, in
      // production, into a request the runtime silently gives up on. It is unref'd again as
      // soon as the wait settles, so it still never holds the process open (Node 24 behaviour
      // is unchanged: the timer fires at the same instant and the same code is thrown).
      await new Promise((resolve) => {
        wake = resolve;
        if (typeof deadlineTimer.ref === 'function') deadlineTimer.ref();
      });
      if (typeof deadlineTimer.unref === 'function') deadlineTimer.unref();
    }
  } finally {
    // Clear the deadline timer on EVERY exit (success, error, or early return
    // when the caller stops consuming) so it never fires against a finished
    // request or leaks. The idle timer is cleared by req.destroy below.
    clearTimeout(deadlineTimer);
    if (!req.destroyed) req.destroy();
  }
}

/**
 * Convenience: collect a full (non-streamed) completion. Returns the answer
 * text plus the separated reasoning and terminal metadata, so callers can build
 * either a plain or reasoning-aware OpenAI response.
 *
 * @returns {Promise<{content: string, reasoning: string,
 *                    finish_reason: string|null, usage: object|null,
 *                    billing: object|null}>}
 */
export async function chat(params) {
  let content = '';
  let reasoning = '';
  let finish_reason = null;
  let usage = null;
  let billing = null;
  for await (const ev of streamChat(params)) {
    if (ev.type === 'content') content += ev.text;
    else if (ev.type === 'reasoning') reasoning += ev.text;
    else if (ev.type === 'finish') { finish_reason = ev.reason; usage = ev.usage; billing = ev.billing; }
  }
  return { content, reasoning, finish_reason, usage, billing };
}

/**
 * Non-stream completion with bounded retry on transient failures. Safe to retry
 * because chat() buffers the whole answer — a mid-stream blip discards a partial
 * buffer and starts clean (no duplicated tokens). Non-retryable errors
 * (MODEL_BLOCKED, UNAUTHORIZED) throw immediately so the caller can map them.
 *
 * @param {object} params  see streamChat
 * @param {number} [params.maxRetries=2]
 * @param {number} [params.retryBaseMs=400]
 */
export async function chatWithRetry(params = {}) {
  const { maxRetries = 2, retryBaseMs = 400 } = params;
  let lastErr = null;
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await chat(params);
    } catch (err) {
      lastErr = err;
      if (!isRetryable(err) || attempt === maxRetries) throw err;
      const backoff = retryBaseMs * 2 ** attempt;
      log.warn(`DEVIN_CONNECT: retryable error (${err.code || err.message}); retry ${attempt + 1}/${maxRetries} in ${backoff}ms`);
      await new Promise((r) => setTimeout(r, backoff));
    }
  }
  throw lastErr;
}

export const __testing = {
  buildClientMetadata, buildCompletionConfig, generateFingerprint,
  messageText, f64le, SOURCE, encodeImageData, parseBillingTagMap,
  encodeToolDef, parseToolCallTagMap, decodeToolCalls, decodeOneToolCall,
  looksLikeValidJson, parseSignatureTagMap,
  encodeAssistantToolCall, encodeChatMessage,
};