Ctrl K
ComunidadGuías…

src/backend-router.js

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

Archivo original ↗Preguntar a KiBot
/**
 * Backend router — makes the implicit "which backend" decision explicit.
 *
 * Historically the backend choice was scattered across handlers/chat.js and
 * special-agent.js as inline branches. This module centralizes that decision
 * into one pure function so the multi-backend migration (Cascade ↔ Devin) has
 * a single, testable seam.
 *
 * BEHAVIOUR-PRESERVING: as of P1 this returns exactly what the inline logic in
 * chat.js:1789-1806 produced. No new routing is introduced yet — Devin REST is
 * defined as a backend constant but only selected when explicitly enabled via
 * env, which defaults OFF. Later phases (P2/P3) extend selectBackend() with
 * entitlement + availability inputs without touching the call sites.
 *
 * Decision order (matches legacy):
 *   1. modelInfo.backend === 'special_agent'         → BACKEND.DEVIN_ACP / DEVIN_PRINT
 *      (the special-agent path; its sub-mode comes from DEVIN_CLI_MODE)
 *   2. modelUid (string) OR enumValue > 0             → BACKEND.CASCADE
 *   3. otherwise                                       → BACKEND.LEGACY
 */

import { getBackendSwitch } from './runtime-config.js';

export const BACKEND = Object.freeze({
  CASCADE: 'cascade',        // Connect-RPC → server.codeium.com (StartCascade flow)
  LEGACY: 'legacy',          // RawGetChatMessage (deprecated, enum-only models)
  DEVIN_ACP: 'devin-acp',    // Devin CLI ACP over stdio (special-agent, mode=acp)
  DEVIN_PRINT: 'devin-print',// Devin CLI print mode (special-agent, mode=print)
  DEVIN_REST: 'devin-rest',  // Devin DRS REST → api.devin.ai (P2+, not yet wired)
  DEVIN_CONNECT: 'devin-connect', // Direct cloud GetChatMessage over pure HTTP
                                  // (no local CLI) → server.codeium.com. See
                                  // src/devin-connect.js + devin-connect-openai.js.
});

/**
 * Is the given model info routed to the special-agent (Devin CLI) backend?
 * Mirrors special-agent.js isSpecialAgentModelInfo without importing it, to
 * keep this module free of side effects.
 */
function isSpecialAgentInfo(modelInfo) {
  return modelInfo?.backend === 'special_agent';
}

function specialAgentEnabled(env = process.env) {
  if (getBackendSwitch('devinOnly', env)) return true;
  const backend = String(env?.WINDSURFAPI_SPECIAL_AGENT_BACKEND || '').trim().toLowerCase();
  return backend === 'devin-cli' || env?.DEVIN_CLI_ENABLED === '1';
}

// SKUs Cascade rejects with "This model is only in Devin Local": the gpt-5.6
// luna/sol/terra family and the gpt-6 luna/sol/astra (+ 6.1 sol) family. The
// effort tier and the -priority lane suffix are optional, matching the catalog
// names in models.js. Anchored to the family names so lookalikes (gpt-5.60,
// gpt-6-lunar, gpt-60-*) never hijack routing. swe-2* is catalogued as
// backend: 'special_agent' and is handled by that branch.
const DEVIN_LOCAL_ONLY_SKU = /^(?:(?:gpt-5[.-]6|gpt5[.-]6)-(?:luna|sol|terra)|(?:gpt-6|gpt6)(?:[.-]1)?-(?:luna|sol|astra))(?:-(?:none|low|medium|high|xhigh|max)(?:-priority)?)?$/;

function isDevinLocalOnlyModel(modelInfo) {
  return [modelInfo?.name, modelInfo?.modelUid].some((k) => DEVIN_LOCAL_ONLY_SKU.test(String(k || '').trim().toLowerCase()));
}

/**
 * Resolve the Devin CLI sub-mode (acp vs print). Defaults to print — the same
 * conservative default special-agent.js uses.
 */
function devinCliMode(env = process.env) {
  // runtime-config override wins; env is the fallback (getBackendSwitch handles
  // the 'acp'|'print' resolution + historical 'print' default).
  return getBackendSwitch('devinCliMode', env) === 'acp' ? BACKEND.DEVIN_ACP : BACKEND.DEVIN_PRINT;
}

/**
 * DEVIN_ONLY kill-switch. When set, Cascade is fully retired and EVERY request
 * — regardless of model — is routed through the Devin CLI special-agent
 * backend. This is the "Devin is the only core" mode for after the Cascade
 * upstream is decommissioned. Defaults OFF, so behaviour is unchanged until an
 * operator flips it.
 *
 * NOTE (model selection is settled; per-account serving is not): routing a model like
 * claude-4.5-sonnet here makes Devin the *nominal* backend. The ACP path passes that model
 * to the CLI launch as `devin acp --model <key>` (devin-acp.js:530-531) and ALSO adds a
 * prompt-side hint alongside it (devin-acp.js:579-582). The CLI's `--model` **does** switch
 * the underlying core, and the name is resolved server-side — but in TWO different places, and
 * merging them into one is the wrong reading: **router-style names** (the `adaptive` /
 * `arena-*` family) resolve through the AssignModel RPC — its own log line is literally
 * `Resolving model router '' via AssignModel RPC` and its method path is
 * `/exa.api_server_pb.ApiServerService/AssignModel` — while **ordinary fuzzy names** (the
 * family slug / alias / partial name vocabulary the CLI's help promises, "the same fuzzy
 * names as /model") are matched against the **server-side model catalog**
 * (`GetCliModelConfigs`); the binary carries no local name→uid table (whether every fuzzy name
 * takes that path is beyond static evidence). That a `--model` value is
 * resolved rather than ignored is also carried by a runtime string: `devin acp --model` value
 * did not resolve to an available model → falls back to the configured `agent.model`
 * (file offset 0x80051f0). A prompt hint cannot produce server-side resolution, and the client
 * never resolves a uid locally (old ledger §1c:
 * extra-hands/RESEARCH/windsurfapi-verification-ledger-2026-10-01.md; corrected attribution +
 * new strings: board ACPMAP, windsurf-re-20261015/derived/acpmap/L-ASSIGNMODEL.md §5.10/§6).
 * So model selection is a routing change, not a capability gap. Honest boundary: the last hop is
 * not statically provable — that the downstream inference request really carries the switched
 * model needs one live ACP session — and whether a given account can *serve* a specific model
 * (entitlement) stays live-only; the tool layer remains the real gap: special-agent.js:804
 * hard-400s any request carrying caller-local tools[].
 */
function devinOnlyEnabled(env = process.env) {
  return getBackendSwitch('devinOnly', env);
}

/**
 * DEVIN_CONNECT kill-switch. When set, every request is served by the direct
 * cloud GetChatMessage path (src/devin-connect.js) — pure HTTP to
 * server.codeium.com with NO local Devin CLI subprocess. This is the deploy
 * mode for hosts that have the Windsurf session token but can't (or shouldn't)
 * run the CLI. Defaults OFF.
 *
 * Wins over DEVIN_ONLY (CLI) and all model-based routing: when an operator opts
 * into the pure-HTTP egress they mean it for the whole process. The model name
 * still flows through unchanged so devin-connect maps it to the upstream
 * selector (field #21). Verified working on a free account with swe-1-6-* (see
 * memory: devin-connect-WORKING-recipe-2026-06-30); claude-* selectors are
 * gated on a paid-account probe.
 */
function devinConnectEnabled(env = process.env) {
  return getBackendSwitch('devinConnect', env);
}

/**
 * Select the backend for a request. Pure function — no I/O, no mutation.
 *
 * @param {object} params
 * @param {object|null} params.modelInfo  resolved model catalog entry
 * @param {object} [params.env]           env source (injectable for tests)
 * @returns {{ backend: string, reason: string, flow: 'special_agent'|'cascade'|'legacy' }}
 */
export function selectBackend({ modelInfo = null, env = process.env } = {}) {
  // DEVIN_CONNECT: pure-HTTP cloud egress retires both Cascade AND the local
  // CLI. Highest precedence — an operator who flips this wants every request on
  // the direct GetChatMessage path regardless of model or DEVIN_ONLY.
  if (devinConnectEnabled(env)) {
    return {
      backend: BACKEND.DEVIN_CONNECT,
      reason: 'devin_connect',
      flow: 'devin_connect',
    };
  }

  // DEVIN_ONLY: Cascade is retired — force every request onto Devin. This wins
  // over all model-based routing below. The sub-mode (acp/print) still comes
  // from DEVIN_CLI_MODE so the existing runner selection is preserved.
  if (devinOnlyEnabled(env)) {
    return {
      backend: devinCliMode(env),
      reason: 'devin_only',
      flow: 'special_agent',
    };
  }

  if (isSpecialAgentInfo(modelInfo)) {
    return {
      backend: devinCliMode(env),
      reason: 'modelInfo.backend=special_agent',
      flow: 'special_agent',
    };
  }

  if (specialAgentEnabled(env) && isDevinLocalOnlyModel(modelInfo)) {
    return {
      backend: devinCliMode(env),
      reason: 'devin_local_only',
      flow: 'special_agent',
    };
  }

  const modelEnum = modelInfo?.enumValue || 0;
  const modelUid = modelInfo?.modelUid || null;
  if (modelUid || modelEnum) {
    return { backend: BACKEND.CASCADE, reason: modelUid ? 'modelUid' : 'enumValue', flow: 'cascade' };
  }

  return { backend: BACKEND.LEGACY, reason: 'no-uid-no-enum', flow: 'legacy' };
}

/**
 * Convenience: does this selection use the Cascade Connect-RPC flow? Call sites
 * in chat.js currently compute `useCascade = !!(modelUid || modelEnum)`; this
 * keeps that exact semantics so the router can be dropped in without behaviour
 * change.
 */
export function usesCascadeFlow(selection) {
  return selection?.flow === 'cascade';
}

export const __testing = { isSpecialAgentInfo, devinCliMode, devinOnlyEnabled, devinConnectEnabled };