Ctrl K
ComunidadGuías…

test/connect-degrade-honesty.test.js

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

Archivo original ↗Preguntar a KiBot
// WINDSURFAPI_STRICT_MODEL=0 degrades an unmapped model to the free selector. The
// degrade is a deliberate operator opt-out. Reporting the PAID name back as if it ran
// is not — and that is what the response used to do.
//
// Measured before this fix: `claude-opus-4.9` returned 200 with
// `model: "claude-opus-4.9"` on the non-stream body AND on every stream chunk, plus a
// system_fingerprint derived from that name, while `swe-1-6-slow` ran upstream. Wrong
// output, wrong billing attribution, and a client that trusts the echoed name is
// silently misled. #234's acceptance criteria call this out explicitly.
//
// Nothing in the suite asserted the echoed name on this path — verified by instrumenting
// the `mapped === false` branch and running all test files: exactly one test reaches it
// (devin-connect-strict-model.test.js:62) and it only asserts "not a 400". So this file
// is the first coverage of the contract.
//
// The load-bearing subtlety is WHICH name to report. See the router-hop test below.

import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { handleChatCompletions, __setConnectDeps } from '../src/handlers/chat.js';
import { addAccountByKey, removeAccount, getAccountInternal } from '../src/auth.js';
// The fingerprint case below runs the REAL adapter (no toChatCompletion stub) so it
// asserts the production derivation, not a fixture's copy of it: the adapter builds
// system_fingerprint as systemFingerprint(displayModel || params.model)
// (src/devin-connect-openai.js:565), and __setStreamChatForTest is that module's
// documented offline seam (same usage as test/devin-connect-openai.test.js).
import { toChatCompletion as realToChatCompletion, __setStreamChatForTest } from '../src/devin-connect-openai.js';
import { systemFingerprint } from '../src/system-fingerprint.js';

const FREE_SELECTOR = 'swe-1-6-slow';
const UNMAPPED_PAID = 'claude-opus-4.9';
const MAPPED_ALIAS = 'claude-sonnet-4.6';

const SAVED = {};
const ENV_KEYS = ['DEVIN_CONNECT', 'WINDSURFAPI_STRICT_MODEL', 'DEVIN_CONNECT_TOKEN', 'WINDSURF_API_KEY', 'DEVIN_CONNECT_ASSIGN_MODEL'];
for (const k of ENV_KEYS) SAVED[k] = process.env[k];

/** Records what the adapter was handed, so we can compare wire vs echoed name. */
let seen = null;
const seeded = [];

/**
 * Put one usable account in the pool.
 *
 * Required for the AssignModel router-hop tests: connectParams.token is only set from an
 * ACQUIRED account (chat.js, `if (ccAcct) connectParams.token = ccAcct.apiKey`), and the
 * hop is gated on that token being present. With an env token alone the hop is skipped
 * and the router assertions fail on their own precondition rather than on the contract —
 * which is indistinguishable from a passing mutation if you only read pass/fail counts.
 */
function seedAccount() {
  const a = addAccountByKey('sk-honesty-' + Math.random().toString(36).slice(2, 12), 'honesty');
  const acct = getAccountInternal(a.id);
  acct.status = 'active';
  acct.tier = 'pro';
  seeded.push(a.id);
  return acct;
}

function stubAdapter() {
  seen = null;
  __setConnectDeps({
    toChatCompletion: async (params, meta) => {
      seen = { wire: params.model, echoed: meta.displayModel };
      return {
        status: 200,
        body: {
          id: 'chatcmpl-test', object: 'chat.completion', created: 0,
          // Mirror the real adapter's precedence: displayModel wins when provided.
          model: meta.displayModel || params.model,
          choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
          usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
        },
      };
    },
  });
}

async function chat(model) {
  const res = await handleChatCompletions(
    { model, messages: [{ role: 'user', content: 'hi' }], stream: false },
    { reqId: 'honesty', callerKey: 'honesty-caller', specialAgent: {} },
  );
  return {
    status: res?.status ?? null,
    bodyModel: res?.body?.model ?? null,
    fingerprint: res?.body?.system_fingerprint ?? null,
    errCode: res?.body?.error?.code ?? null,
    wire: seen?.wire ?? null,
    echoed: seen?.echoed ?? null,
  };
}

beforeEach(() => {
  process.env.DEVIN_CONNECT = '1';
  process.env.DEVIN_CONNECT_TOKEN = 'test-token-shape-only';
  delete process.env.WINDSURF_API_KEY;
  stubAdapter();
});

afterEach(() => {
  while (seeded.length) removeAccount(seeded.pop());
  __setConnectDeps(null);
  __setStreamChatForTest(null);
  seen = null;
  for (const k of ENV_KEYS) {
    if (SAVED[k] === undefined) delete process.env[k];
    else process.env[k] = SAVED[k];
  }
});

describe('degrade honesty: the echoed model must be what ran (#234)', () => {
  it('reports the free selector, not the requested paid name, when degrading', async () => {
    process.env.WINDSURFAPI_STRICT_MODEL = '0';

    const r = await chat(UNMAPPED_PAID);

    assert.equal(r.status, 200, 'the opt-out still serves the request');
    assert.equal(r.wire, FREE_SELECTOR, 'precondition: the free selector is what actually ran');
    assert.equal(r.bodyModel, FREE_SELECTOR,
      `the response must not claim to have run ${UNMAPPED_PAID} when ${FREE_SELECTOR} ran`);
    assert.notEqual(r.bodyModel, UNMAPPED_PAID);
  });

  it('still echoes the REQUESTED name for a mapped alias', async () => {
    // Alias resolution is not a degrade: claude-sonnet-4.6 → claude-sonnet-4-6-thinking
    // is the same model under its canonical selector. OpenAI echoes the requested alias
    // here, and so must we — a fix that reported the resolved selector for mapped names
    // would be a behaviour change with no defect behind it.
    process.env.WINDSURFAPI_STRICT_MODEL = '0';

    const r = await chat(MAPPED_ALIAS);

    assert.equal(r.status, 200);
    assert.notEqual(r.wire, MAPPED_ALIAS, 'precondition: the wire name differs from the alias');
    assert.equal(r.bodyModel, MAPPED_ALIAS,
      'a mapped alias must keep echoing the requested name');
  });

  it('is consistent when the requested name IS the selector', async () => {
    process.env.WINDSURFAPI_STRICT_MODEL = '0';

    const r = await chat(FREE_SELECTOR);

    assert.equal(r.wire, FREE_SELECTOR);
    assert.equal(r.bodyModel, FREE_SELECTOR);
  });

  it('leaves strict mode rejecting rather than degrading', async () => {
    // With the guard ON (the default) an unmapped name never reaches the degrade at all,
    // so honesty is moot — it 400s. Pinned so the honesty fix can't be mistaken for a
    // reason to relax the guard.
    process.env.WINDSURFAPI_STRICT_MODEL = '1';

    const r = await chat(UNMAPPED_PAID);

    assert.equal(r.status, 400);
    assert.equal(r.errCode, 'model_not_found');
    assert.equal(r.wire, null, 'nothing reached the adapter');
  });

  it('reports the ROUTER-ASSIGNED model, not the free selector, after an AssignModel hop', async () => {
    // The trap. A router name like `adaptive` also resolves mapped:false, so it takes the
    // same branch — but the AssignModel hop reassigns connectParams.model to a concrete
    // uid AFTER `selector` was captured. Reporting `selector` here (which is what
    // HANDOFF-2026-08-03-B.md §3.6 literally prescribed) swaps one lie for another: a
    // request that really ran claude-opus-4-8-medium gets reported as the free
    // swe-1-6-slow. Reproduced both ways before choosing connectParams.model.
    //
    // Without this test the distinction is invisible: a mutation replacing
    // connectParams.model with selector passes every other assertion in this file.
    process.env.WINDSURFAPI_STRICT_MODEL = '0';
    process.env.DEVIN_CONNECT_ASSIGN_MODEL = '1';

    seedAccount();
    const ASSIGNED = 'claude-opus-4-8-medium';
    __setConnectDeps({
      assignModel: async () => ({ model_uid: ASSIGNED }),
    });

    const r = await chat('adaptive');

    assert.equal(r.wire, ASSIGNED,
      'precondition: the router hop put the assigned uid on the wire');
    assert.equal(r.bodyModel, ASSIGNED,
      'the echoed name must be the model the router actually assigned — reporting the '
      + 'pre-hop selector would report a free model for a paid run');
    assert.notEqual(r.bodyModel, FREE_SELECTOR);
    assert.notEqual(r.bodyModel, 'adaptive');
  });

  it('falls back to the selector when the AssignModel hop fails', async () => {
    // A failed resolve degrades to the original selector, so THAT is what ran and what
    // must be reported. Pins the other side of the branch.
    process.env.WINDSURFAPI_STRICT_MODEL = '0';
    process.env.DEVIN_CONNECT_ASSIGN_MODEL = '1';

    seedAccount();
    __setConnectDeps({
      assignModel: async () => { throw Object.assign(new Error('nope'), { code: 'ASSIGN_FAILED' }); },
    });

    const r = await chat('adaptive');

    assert.equal(r.wire, FREE_SELECTOR, 'precondition: it degraded to the selector');
    assert.equal(r.bodyModel, FREE_SELECTOR, 'and that is what must be reported');
  });

  it('derives system_fingerprint from the echoed name, not the requested one', async () => {
    // The fingerprint is keyed to the model. If it stayed keyed to the requested paid
    // name it would contradict the model field we now report honestly.
    //
    // Runs the REAL adapter (the toChatCompletion stub is replaced for this case), so
    // the fingerprint comes from production code — devin-connect-openai.js:565 keys it
    // to `displayModel || params.model` — rather than from a fixture that could only
    // ever agree with itself. The upstream is faked through that module's documented
    // seam; the fake yields content so the retry-on-empty wrapper does not re-issue.
    //
    // The guard this replaced (`if (typeof systemFingerprint !== 'function') return;`)
    // was never false: chat.js imports systemFingerprint but does not re-export it, so
    // the case returned early and asserted nothing. Mutating chat.js's connectDisplayModel
    // back to reqModelName turns it red (measured).
    process.env.WINDSURFAPI_STRICT_MODEL = '0';
    __setStreamChatForTest(async function* () {
      yield { type: 'content', text: 'ok' };
      yield { type: 'finish', reason: 'stop', usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
    });
    __setConnectDeps({ toChatCompletion: realToChatCompletion });

    const r = await chat(UNMAPPED_PAID);

    // Fingerprint first: on the degrade path the honest model name and the honest
    // fingerprint source are the same string, so a displayModel mutation breaks both —
    // asserting the fingerprint before the model field is what makes a failure report
    // the fingerprint contract rather than the sibling one.
    assert.equal(r.fingerprint, systemFingerprint(FREE_SELECTOR),
      'the fingerprint must be keyed to the name we report, not to the requested one');
    assert.notEqual(r.fingerprint, systemFingerprint(UNMAPPED_PAID));
    assert.equal(r.bodyModel, FREE_SELECTOR);
    // Precondition for the check above: the two names must hash differently, or a
    // swapped fingerprint would be invisible to it.
    assert.notEqual(systemFingerprint(FREE_SELECTOR), systemFingerprint(UNMAPPED_PAID),
      'precondition: the two names produce different fingerprints');
  });
});

describe('degrade honesty reaches the Anthropic and Gemini routes too (#234)', () => {
  // The partial-path check. chat.js alone fixes /v1/chat/completions and leaves the other
  // two protocol routes reporting the requested name — measured before this: a
  // /v1/messages request for claude-opus-4.9 ran swe-1-6-slow and reported
  // claude-opus-4.9. Both files had ZERO references to displayModel.

  it('/v1/messages reports what ran, not what was asked for', async () => {
    process.env.WINDSURFAPI_STRICT_MODEL = '0';
    const { handleMessages } = await import('../src/handlers/messages.js');

    const res = await handleMessages(
      { model: UNMAPPED_PAID, max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] },
      { callerKey: 'honesty-caller' },
    );

    assert.equal(seen?.wire, FREE_SELECTOR, 'precondition: the free selector ran');
    assert.equal(res?.body?.model, FREE_SELECTOR,
      'the Anthropic route must not report the paid name either');
  });

  it('/v1/messages still echoes the requested name for a mapped alias', async () => {
    process.env.WINDSURFAPI_STRICT_MODEL = '0';
    const { handleMessages } = await import('../src/handlers/messages.js');

    const res = await handleMessages(
      { model: MAPPED_ALIAS, max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] },
      { callerKey: 'honesty-caller' },
    );

    assert.equal(res?.body?.model, MAPPED_ALIAS,
      'alias resolution is not a degrade — the requested name still stands');
  });
});