Ctrl K
ComunidadGuías…

test/connect-discovery-rebuild.test.js

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

Archivo original ↗Preguntar a KiBot
// #234 / #231-in-the-connect-namespace — /v1/models must agree with what chat
// can actually run.
//
// Two defects, one symptom:
//
//   1. Existence was the ONLY test, so a free-only pool advertised every paid
//      selector the upstream publishes. The client picked one and got a 403 at
//      chat. #232 fixed this for the Cascade namespace, but its filters
//      early-return unfiltered when devinConnect is on (a correct namespace
//      boundary), so the check had to be redone here.
//
//   2. The connect live catalog latched after the FIRST account synced and never
//      refreshed, so adding a paid account to a free pool never widened discovery.
//
// The critical trap, and why this is a REBUILD rather than a filter: entitlement
// filtering alone takes a free-only pool to zero rows, and zero rows is worse than
// over-advertising — Codex and Cline refuse to start against an empty model list.
// The free-reachable selector is in NEITHER row source (absent from the frozen
// snapshot, absent from all live catalog rows, no MODELS entry) yet chat routes it,
// so discovery has to synthesize it.

import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  __resetModelCatalogState,
  __setModelCatalogDeps,
  __waitForModelCatalogSync,
  addAccountByKey,
  removeAccount,
  getAccountInternal,
  getAccountCount,
} from '../src/auth.js';
import { handleModels } from '../src/handlers/models.js';
import {
  setLiveCatalogSelectors, __testing,
} from '../src/devin-connect-models.js';

const FREE_SELECTOR = 'swe-1-6-slow';
const created = [];
const SAVED = {};
const ENV_KEYS = ['DEVIN_CONNECT', 'DEVIN_CONNECT_TOKEN', 'WINDSURF_API_KEY'];
for (const k of ENV_KEYS) SAVED[k] = process.env[k];

function mk(tier) {
  const a = addAccountByKey('sk-disc-' + Math.random().toString(36).slice(2, 12), 'disc');
  const acct = getAccountInternal(a.id);
  acct.status = 'active';
  acct.tier = tier;
  created.push(a.id);
  return acct;
}

function ids(env = process.env) {
  return handleModels(env).data.map((m) => m.id);
}

function connectEnv() {
  process.env.DEVIN_CONNECT = '1';
  delete process.env.DEVIN_CONNECT_TOKEN;
  delete process.env.WINDSURF_API_KEY;
}

beforeEach(() => {
  // These tests exercise discovery policy only. Account creation normally starts
  // both Connect and Cascade catalog RPCs; using fixture credentials against the
  // real service made unrelated 401/network failures part of a supposedly local
  // suite. Keep every catalog path deterministic and offline.
  __setModelCatalogDeps({
    disableConnectSync: true,
    getCascadeModelConfigs: async () => ({ configs: [] }),
  });
});

afterEach(async () => {
  while (created.length) removeAccount(created.pop());
  await __waitForModelCatalogSync();
  __resetModelCatalogState();
  __setModelCatalogDeps(null);
  for (const k of ENV_KEYS) {
    if (SAVED[k] === undefined) delete process.env[k];
    else process.env[k] = SAVED[k];
  }
});

describe('connect discovery — entitlement filter (#234)', () => {
  it('does not advertise paid selectors to a free-only pool', () => {
    connectEnv();
    mk('free');

    const rows = ids();
    assert.ok(!rows.includes('claude-opus-4-8-medium'),
      'a free-only pool must not advertise a paid selector it will 403 on');
    assert.ok(!rows.includes('claude-4-sonnet'));
  });

  it('never returns an empty list to a free-only pool', () => {
    // The regression that makes filtering-without-rebuilding worse than the bug:
    // an empty /v1/models makes Codex and Cline refuse to start.
    connectEnv();
    mk('free');

    const rows = ids();
    assert.ok(rows.length > 0, 'zero models breaks client startup entirely');
    assert.ok(rows.includes(FREE_SELECTOR),
      'the one selector any account can run must be discoverable');
  });

  it('widens discovery when a paid account joins the pool', () => {
    connectEnv();
    mk('free');
    const freeOnly = ids();

    mk('pro');
    const withPaid = ids();

    assert.ok(withPaid.length > freeOnly.length,
      `adding a paid account must widen discovery (was ${freeOnly.length}, now ${withPaid.length})`);
    assert.ok(withPaid.includes(FREE_SELECTOR), 'the free selector stays listed');
  });

  it('treats a complete non-empty live catalog as authoritative over the frozen snapshot', () => {
    connectEnv();
    mk('pro');
    setLiveCatalogSelectors([
      { selector: 'claude-opus-4-8-medium', provider: 'anthropic' },
    ]);

    const rows = ids();
    assert.ok(rows.includes('claude-opus-4-8-medium'),
      'the selector confirmed by the live catalog must be advertised');
    assert.ok(!rows.includes('gpt-5.5'),
      'a snapshot-only model must not be advertised after every contributor has live data');
    assert.ok(rows.includes(FREE_SELECTOR), 'the universal free floor stays advertised');
  });

  it('does not log paid-request downgrade warnings while building discovery', () => {
    connectEnv();
    mk('pro');
    setLiveCatalogSelectors([
      { selector: 'claude-opus-4-8-medium', provider: 'anthropic' },
    ]);
    __testing.degradeWarned.clear();

    const warnings = [];
    const originalWarn = console.warn;
    console.warn = (...args) => { warnings.push(args.join(' ')); };
    try {
      ids();
    } finally {
      console.warn = originalWarn;
    }

    assert.deepEqual(
      warnings.filter((line) => line.includes('paid request downgraded to free tier')),
      [],
      'GET /v1/models is a read-only catalog probe, not a paid request',
    );
  });

  it('fails open on an empty pool instead of returning nothing', () => {
    // hasConnectEntitledAccount is Array.some over the pool, so it returns false
    // for EVERY selector when the pool is empty. Filtering without this arm would
    // strip all rows from a deployment that has not added accounts yet.
    connectEnv();
    assert.equal(getAccountCount().total, 0, 'precondition: empty pool');

    const rows = ids();
    assert.ok(rows.length > 1,
      'an empty pool has nothing to filter against, so discovery must fail open');
    assert.ok(rows.includes('claude-opus-4-8-medium'),
      'fail-open means the full connect view, not just the free floor');
  });

  it('exempts an env-token deployment regardless of pool contents', () => {
    // Mirrors the chat path exemption, which checks only that the env token is
    // SET and does not look at pool size. A token + free-account mixed deployment
    // would otherwise advertise a narrow list while chat serves paid selectors.
    connectEnv();
    mk('free');
    const withoutToken = ids();

    process.env.DEVIN_CONNECT_TOKEN = 'env-token-for-test';
    const withToken = ids();

    assert.ok(withToken.length > withoutToken.length,
      'an env token must exempt discovery from pool-based entitlement filtering');
    assert.ok(withToken.includes('claude-opus-4-8-medium'));
  });

  it('keeps snapshot selectors available to an env token beside a restricted live pool', () => {
    connectEnv();
    mk('pro');
    setLiveCatalogSelectors([
      { selector: 'claude-opus-4-8-medium', provider: 'anthropic' },
    ]);

    const rows = ids({
      ...process.env,
      DEVIN_CONNECT_TOKEN: 'per-request-env-token-for-restricted-live-test',
      WINDSURF_API_KEY: '',
    });
    assert.ok(rows.includes('gpt-5.5'),
      'the env token is outside per-account live restrictions and retains snapshot fallback');
  });

  it('applies the filter to the live_catalog producer, not just listModels', () => {
    // handleModels has TWO row producers: the listModels filter and a live_catalog
    // synthesis loop that builds its own rows. Filtering only the first left a
    // free-only pool still advertising every live-only paid selector.
    //
    // The live catalog MUST be populated for this to test anything — it is empty in
    // a fresh process, so without this setup the loop emits nothing and the
    // assertion passes no matter what. (Verified: removing the producer-2 filter
    // did not fail an earlier version of this test for exactly that reason.)
    connectEnv();
    mk('free');

    const LIVE_ONLY_PAID = 'gpt-5-6-sol-max';
    const LIVE_ONLY_FREE = 'swe-1-6-slow';
    setLiveCatalogSelectors([
      { selector: LIVE_ONLY_PAID, provider: 'openai' },
      { selector: LIVE_ONLY_FREE, provider: 'windsurf' },
    ]);

    const rows = handleModels(process.env).data;
    const emitted = rows.map((m) => m.id);

    assert.ok(emitted.includes(LIVE_ONLY_FREE),
      'precondition: the live catalog is populated and its free selector is emitted');
    assert.ok(!emitted.includes(LIVE_ONLY_PAID),
      'the live_catalog producer must apply the entitlement filter too — a free-only ' +
      'pool must not advertise a live-only paid selector');
  });

  it('leaves the non-connect backend view untouched', () => {
    // The connect entitlement filter must not leak into the Cascade transport,
    // whose own catalog policy governs there.
    process.env.DEVIN_CONNECT = '0';
    mk('free');

    const rows = ids({ ...process.env, DEVIN_CONNECT: '0' });
    assert.ok(!rows.includes(FREE_SELECTOR),
      'the connect free floor must not appear on the Cascade transport');
  });
});