unbound-cli 1.20.1 → 1.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.20.1",
3
+ "version": "1.20.2",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -80,6 +80,8 @@ function genericRowToPolicy(row, ref) {
80
80
  } else if (
81
81
  kind === 'model'
82
82
  && ('support_all_models' in config || 'allowed_model_ids' in config || 'excluded_model_ids' in config)
83
+ // Name patterns have no sugar form; as raw config they cannot read as "allow all".
84
+ && !('allowed_model_patterns' in config)
83
85
  // "allow nothing" has no sugar form; keep it as raw config so the file validates.
84
86
  && (config.support_all_models || (config.allowed_model_ids || []).length)
85
87
  ) {
@@ -91,9 +91,26 @@ function normalizeModelConfig(config) {
91
91
  support_all_models: !!config.support_all_models,
92
92
  allowed_model_ids: sortedNumbers(config.allowed_model_ids),
93
93
  excluded_model_ids: sortedNumbers(config.excluded_model_ids),
94
+ // Any match counts, so pattern order is not a change.
95
+ ...(Array.isArray(config.allowed_model_patterns) && { allowed_model_patterns: [...config.allowed_model_patterns].sort() }),
96
+ ...(Array.isArray(config.excluded_model_patterns) && { excluded_model_patterns: [...config.excluded_model_patterns].sort() }),
94
97
  };
95
98
  }
96
99
 
100
+ // The backend stores a fixed model list as exact-name patterns when every id resolves to a name one can spell; plan that stored shape so it does not drift.
101
+ function asStoredModelConfig(config, aiModels) {
102
+ const ids = config && config.allowed_model_ids;
103
+ if (!config || config.support_all_models !== false || 'allowed_model_patterns' in config || !Array.isArray(ids) || !ids.length || !ids.every(Number.isInteger)) return config;
104
+ const found = (aiModels || []).filter((m) => ids.includes(Number(m.id)));
105
+ // An empty list or an id missing from the catalog stays a fixed list, so nothing widens or drops.
106
+ if (new Set(found.map((m) => Number(m.id))).size !== new Set(ids).size) return config;
107
+ const names = [...new Set(found.map((m) => m.name))].sort();
108
+ if (!names.every((name) => /^[A-Za-z0-9._@:-]+$/.test(name))) return config;
109
+ // Excluded ids never applied to a fixed list, so the backend drops them on conversion too.
110
+ const { allowed_model_ids: _ids, excluded_model_ids: _excluded, ...rest } = config;
111
+ return { ...rest, support_all_models: true, allowed_model_patterns: names };
112
+ }
113
+
97
114
  function normalizeConfig(kind, config) {
98
115
  if (kind === 'security') return normalizeSecurityConfig(config);
99
116
  if (kind === 'model') return normalizeModelConfig(config);
@@ -229,6 +246,8 @@ function genericDesired(policy, ref, where) {
229
246
  if (kind === 'security' && policy.guardrails) config = guardrailsToConfig(policy, ref, where);
230
247
  else if (kind === 'model' && policy.models) config = modelsToConfig(policy, ref, where);
231
248
  else config = policy.config || {};
249
+ const unconverted = config;
250
+ if (kind === 'model') config = asStoredModelConfig(config, ref.aiModels);
232
251
 
233
252
  const scopeIds = resolveIds(ref.userGroups, policy.scope && policy.scope.userGroups, 'user group', { where });
234
253
  const toolTypes = [...new Set(policy.scope && policy.scope.toolTypes ? policy.scope.toolTypes : [])].sort();
@@ -260,7 +279,8 @@ function genericDesired(policy, ref, where) {
260
279
  label_ids: labelIds,
261
280
  };
262
281
  if (policy.priority !== undefined) proj.priority = policy.priority;
263
- return { table: 'generic', body, proj, mcp: null };
282
+ const legacyConfig = config !== unconverted ? normalizeConfig(kind, unconverted) : undefined;
283
+ return { table: 'generic', body, proj, mcp: null, legacyConfig };
264
284
  }
265
285
 
266
286
  /**
@@ -449,7 +469,9 @@ function buildPlan(desired, live, { prune = false } = {}) {
449
469
  continue;
450
470
  }
451
471
 
452
- const changes = diffFields(d.proj, liveProj);
472
+ let changes = diffFields(d.proj, liveProj);
473
+ // A model list stored as ids before the backend saved patterns is the same policy.
474
+ if (changes.length && d.legacyConfig && !diffFields({ ...d.proj, config: d.legacyConfig }, liveProj).length) changes = [];
453
475
  if (!changes.length) {
454
476
  ops.push({ ...base, op: 'unchanged', row, changes: [] });
455
477
  continue;
@@ -67,6 +67,15 @@ test('round trip keeps a disabled guardrail entry disabled', () => {
67
67
  assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
68
68
  });
69
69
 
70
+ test('a model policy stored as name patterns exports as raw config and plans unchanged', () => {
71
+ const live = { tool: [], generic: [{ ...LIVE.generic[1], config: { support_all_models: true, allowed_model_patterns: ['claude-sonnet'], excluded_model_ids: [] } }] };
72
+ const doc = exp.liveToPack('base', live, REF);
73
+ assert.equal(doc.policies[0].models, undefined);
74
+ assert.deepEqual(doc.policies[0].config, live.generic[0].config);
75
+ const ops = plan.buildPlan(plan.toDesired(schema.validatePack(doc, 'base.yaml'), REF), live, { prune: true });
76
+ assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
77
+ });
78
+
70
79
  test('export keeps a loadable file for an empty BLOCK message and an allow-nothing model policy', () => {
71
80
  const live = {
72
81
  tool: [{ ...LIVE.tool[0], name: 'quiet-block', code_slug: 'quiet-block', custom_message: '' }],
@@ -67,6 +67,23 @@ test('buildPlan: in-sync org yields only unchanged ops', () => {
67
67
  assert.equal(plan.summarize(ops).drift, 0);
68
68
  });
69
69
 
70
+ test('buildPlan: an allowed-model list matches the name patterns the backend stores it as', () => {
71
+ const desired = plan.toDesired(pack([{ ...MODEL, models: { allowed: ['gpt-4o', 'claude-sonnet'] } }]), REF);
72
+ assert.deepEqual(desired[0].body.config, { support_all_models: true, allowed_model_patterns: ['claude-sonnet', 'gpt-4o'] });
73
+ const live = { ...LIVE_MODEL, config: { support_all_models: true, allowed_model_patterns: ['claude-sonnet', 'gpt-4o'] } };
74
+ assert.deepEqual(plan.buildPlan(desired, { tool: [], generic: [live] }).map((o) => o.op), ['unchanged']);
75
+ const reordered = { ...live, config: { ...live.config, allowed_model_patterns: ['gpt-4o', 'claude-sonnet'] } };
76
+ assert.deepEqual(plan.buildPlan(desired, { tool: [], generic: [reordered] }).map((o) => o.op), ['unchanged']);
77
+ });
78
+
79
+ test('toDesired: an empty or partly unknown id list stays a fixed list', () => {
80
+ for (const ids of [[], [100, 999]]) {
81
+ const config = { support_all_models: false, allowed_model_ids: ids, excluded_model_ids: [] };
82
+ const [d] = plan.toDesired(pack([{ id: 'models', kind: 'model', config }]), REF);
83
+ assert.deepEqual(d.body.config, config);
84
+ }
85
+ });
86
+
70
87
  test('buildPlan: create, patch-update, orphan and prune', () => {
71
88
  const desired = plan.toDesired(pack([{ ...TERMINAL, action: 'AUDIT', customMessage: undefined }, { ...COST, config: { monthly_budget: 250 } }]), REF);
72
89
  const live = { tool: [LIVE_TERMINAL, LIVE_MCP], generic: [LIVE_COST] };