W
File size: 3,990 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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/**
 * Model access control — allow/block specific models.
 * Persisted to model-access.json.
 */

import { readFileSync, existsSync } from 'fs';
import { writeJsonAtomic } from '../fs-atomic.js';
import { join } from 'path';
import { config, log } from '../config.js';

const ACCESS_FILE = join(config.dataDir, 'model-access.json');

// mode: 'allowlist' (only listed models allowed) | 'blocklist' (listed models blocked) | 'all' (no restrictions)
const _config = {
  mode: 'all',
  list: [],          // model IDs in the list
};

// Load
try {
  if (existsSync(ACCESS_FILE)) {
    Object.assign(_config, JSON.parse(readFileSync(ACCESS_FILE, 'utf-8')));
  }
} catch (e) {
  log.error('Failed to load model-access.json:', e.message);
}

function save() {
  try {
    writeJsonAtomic(ACCESS_FILE, _config);
  } catch (e) {
    log.error('Failed to save model-access.json:', e.message);
  }
}

export function getModelAccessConfig() {
  return { ..._config };
}

export function setModelAccessMode(mode) {
  if (!['all', 'allowlist', 'blocklist'].includes(mode)) return;
  _config.mode = mode;
  save();
}

export function setModelAccessList(list) {
  _config.list = Array.isArray(list) ? list : [];
  save();
}

export function addModelToList(modelId) {
  if (!_config.list.includes(modelId)) {
    _config.list.push(modelId);
    save();
  }
}

export function removeModelFromList(modelId) {
  _config.list = _config.list.filter(m => m !== modelId);
  save();
}

/**
 * Some models in the catalog are simply the reasoning-mode variant of a
 * base model (claude-opus-4.6 vs claude-opus-4.6-thinking). For
 * allowlist/blocklist purposes we treat the `-thinking` suffix as
 * inheriting from its base — otherwise a user who carefully added
 * `claude-opus-4.6` to their allowlist still gets a 403 the moment
 * they ask for `-thinking`, with no obvious connection to anything
 * they've configured (#103).
 *
 * Other variant suffixes (-fast, -1m, -low/medium/high/xhigh, -mini,
 * -nano, -codex, -max-*) are intentionally NOT inherited — those are
 * distinct entitlements (different context window, latency tier,
 * pricing, or model architecture) where treating them interchangeably
 * would surprise users who actually want fine-grained gating.
 */
function siblingsForAllowlist(modelId) {
  const sibs = [];
  if (modelId.endsWith('-thinking')) {
    sibs.push(modelId.slice(0, -'-thinking'.length));
  } else {
    sibs.push(modelId + '-thinking');
  }
  return sibs;
}

/**
 * Check if a model is allowed.
 * @returns {{ allowed: boolean, reason?: string }}
 */
export function isModelAllowed(modelId) {
  if (_config.mode === 'all') return { allowed: true };

  if (_config.mode === 'allowlist') {
    if (_config.list.includes(modelId)) return { allowed: true };
    // Auto-inherit between base model and its -thinking variant so
    // users don't have to enumerate both halves of every reasoning
    // pair. Only base→thinking inheritance happens in practice (the
    // user typed `claude-opus-4.6` in the dashboard, the request asks
    // for `claude-opus-4.6-thinking`); the symmetric direction is
    // included for completeness.
    for (const sib of siblingsForAllowlist(modelId)) {
      if (_config.list.includes(sib)) return { allowed: true };
    }
    return { allowed: false, reason: `模型 ${modelId} 不在允許清單中` };
  }

  if (_config.mode === 'blocklist') {
    if (_config.list.includes(modelId)) {
      return { allowed: false, reason: `模型 ${modelId} 已被封鎖` };
    }
    // Same inheritance for blocklist: blocking the base also blocks
    // the -thinking variant, so an operator who put `claude-opus-4.6`
    // on the blocklist isn't surprised by `-thinking` slipping past.
    for (const sib of siblingsForAllowlist(modelId)) {
      if (_config.list.includes(sib)) {
        return { allowed: false, reason: `模型 ${modelId} 已被封鎖` };
      }
    }
    return { allowed: true };
  }

  return { allowed: true };
}