W
File size: 4,245 Bytes
2b64d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// #103 (denvey): allowlist/blocklist must auto-inherit between a base
// model and its `-thinking` reasoning variant.
//
// Pre-fix UX bug: the dashboard surfaces base names (e.g.
// `claude-opus-4.6`); a user who carefully allowlists that name still
// gets a 403 the moment a request resolves to `claude-opus-4.6-thinking`,
// with no obvious connection to anything they configured. Same trap on
// the blocklist side: an operator who blocks the base would be surprised
// to see -thinking slip past.
//
// Other suffixes (-fast, -1m, -low/medium/high/xhigh, -mini, -nano,
// -codex, -max-*) are intentionally NOT inherited — those represent
// distinct entitlements (different context window, latency tier,
// pricing, or model architecture).

import { after, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import {
  getModelAccessConfig,
  isModelAllowed,
  setModelAccessList,
  setModelAccessMode,
} from '../src/dashboard/model-access.js';

const original = getModelAccessConfig();
after(() => {
  setModelAccessMode(original.mode);
  setModelAccessList(original.list);
});

describe('isModelAllowed thinking-variant inheritance (#103)', () => {
  test('allowlist: base entry implies the -thinking sibling', () => {
    setModelAccessMode('allowlist');
    setModelAccessList(['claude-opus-4.6']);
    assert.equal(isModelAllowed('claude-opus-4.6').allowed, true);
    assert.equal(isModelAllowed('claude-opus-4.6-thinking').allowed, true,
      'allowlisting the base must auto-allow the -thinking sibling');
  });

  test('allowlist: -thinking entry implies the base sibling', () => {
    setModelAccessMode('allowlist');
    setModelAccessList(['claude-sonnet-4.6-thinking']);
    assert.equal(isModelAllowed('claude-sonnet-4.6-thinking').allowed, true);
    assert.equal(isModelAllowed('claude-sonnet-4.6').allowed, true);
  });

  test('allowlist: unrelated suffixes (-fast / -1m / -high) are NOT inherited', () => {
    setModelAccessMode('allowlist');
    setModelAccessList(['claude-opus-4.6']);
    // These represent distinct entitlements (context window, tier,
    // pricing) — they must remain individually gated.
    assert.equal(isModelAllowed('claude-opus-4.6-fast').allowed, false);
    assert.equal(isModelAllowed('claude-opus-4.6-1m').allowed, false);
    assert.equal(isModelAllowed('claude-opus-4.6-high').allowed, false);
  });

  test('allowlist: empty list rejects everything (including -thinking)', () => {
    setModelAccessMode('allowlist');
    setModelAccessList([]);
    assert.equal(isModelAllowed('claude-opus-4.6').allowed, false);
    assert.equal(isModelAllowed('claude-opus-4.6-thinking').allowed, false);
  });

  test('blocklist: base entry also blocks the -thinking sibling', () => {
    setModelAccessMode('blocklist');
    setModelAccessList(['claude-opus-4.6']);
    const baseRes = isModelAllowed('claude-opus-4.6');
    const thinkRes = isModelAllowed('claude-opus-4.6-thinking');
    assert.equal(baseRes.allowed, false);
    assert.equal(thinkRes.allowed, false,
      'blocking the base must auto-block the -thinking sibling');
    assert.match(thinkRes.reason || '', /-thinking|claude-opus-4\.6/);
  });

  test('blocklist: -thinking entry also blocks the base sibling', () => {
    setModelAccessMode('blocklist');
    setModelAccessList(['claude-sonnet-4.6-thinking']);
    assert.equal(isModelAllowed('claude-sonnet-4.6-thinking').allowed, false);
    assert.equal(isModelAllowed('claude-sonnet-4.6').allowed, false);
  });

  test('blocklist: unrelated suffixes pass when only the base is blocked', () => {
    setModelAccessMode('blocklist');
    setModelAccessList(['claude-opus-4.6']);
    assert.equal(isModelAllowed('claude-opus-4.6-fast').allowed, true);
    assert.equal(isModelAllowed('claude-opus-4.6-mini').allowed, true);
    assert.equal(isModelAllowed('claude-opus-4.6-codex').allowed, true);
  });

  test('mode=all bypasses inheritance entirely (everything allowed)', () => {
    setModelAccessMode('all');
    setModelAccessList(['claude-opus-4.6']);  // list ignored in 'all' mode
    assert.equal(isModelAllowed('claude-opus-4.6-thinking').allowed, true);
    assert.equal(isModelAllowed('any-other-model').allowed, true);
  });
});