@vib-rato/ai 0.18.0 → 0.19.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.19.0] - 2026-09-14
6
+
7
+ ### Added
8
+
9
+ - Air-gapped mode support (`VIB_AIRV=1`, see the coding-agent changelog): the models.dev catalog fetch, `AuthStorage.fetchUsageReports`, both auth-broker request paths, OAuth token refresh and API-key acquisition, and proxy resolution return early or refuse before any network is touched; the proxied TLS socket path classifies its destination; and `SqliteAuthCredentialStore` accepts `excludeProviders` so excluded providers' credential rows are never read from SQLite. Provider model discovery against a private endpoint is unaffected.
10
+
5
11
  ## [0.18.0] - 2026-09-08
6
12
 
7
13
  - Transport failure facts now retain the Vibrato Usage Gateway's `x-vug-daily-limit`, `x-vug-daily-used`, `x-vug-daily-remaining`, `x-vug-daily-reset`, `x-vug-queue-depth`, `x-vug-inflight`, and `x-vug-queued-ms` headers (raw string values, allowlisted like the existing `retry-after`/`retry-after-ms` entries), and classify a gateway `daily_token_limit` provider code as a `quota` fallback trigger instead of `rate_limit`. Previously the gateway's daily-limit 429 was treated as an ordinary rate limit with a 12-hour retry window, and the `x-vug-daily-*` headers were dropped before reaching consumers. A code-less 429 still classifies as `rate_limit`, and 503 `queue_timeout`/`queue_full` still classify as `server`.
@@ -1013,10 +1013,24 @@ export declare class AuthStorage {
1013
1013
  * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
1014
1014
  * without going through `AuthStorage`.
1015
1015
  */
1016
+ /**
1017
+ * `excludeProviders` removes those providers from every credential *read* at
1018
+ * the SQL level: the listing statements carry a `provider NOT IN (…)` clause,
1019
+ * so the `data` column of an excluded row is never fetched and its payload
1020
+ * never enters the process. Writes are untouched — this hides rows from a
1021
+ * reader, it does not make the store forget them.
1022
+ *
1023
+ * The air-gapped mode (`airv`) is the caller: hosted-provider credentials must
1024
+ * not be read at all, while local-endpoint keys live in the same database and
1025
+ * must keep working.
1026
+ */
1027
+ export interface SqliteAuthCredentialStoreOptions {
1028
+ excludeProviders?: readonly string[];
1029
+ }
1016
1030
  export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
1017
1031
  #private;
1018
- constructor(db: Database);
1019
- static open(dbPath?: string): Promise<SqliteAuthCredentialStore>;
1032
+ constructor(db: Database, options?: SqliteAuthCredentialStoreOptions);
1033
+ static open(dbPath?: string, options?: SqliteAuthCredentialStoreOptions): Promise<SqliteAuthCredentialStore>;
1020
1034
  listAuthCredentials(provider?: string): StoredAuthCredential[];
1021
1035
  listCredentialInventory(provider?: string): CredentialInventoryRecord[];
1022
1036
  listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@vib-rato/ai",
4
- "version": "0.18.0",
4
+ "version": "0.19.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/Keonho-Chu/Vibrato",
7
7
  "author": "Yeachan-Heo and Vibrato Contributors",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@vib-rato/natives": "0.18.0",
44
- "@vib-rato/utils": "0.18.0",
43
+ "@vib-rato/natives": "0.19.0",
44
+ "@vib-rato/utils": "0.19.0",
45
45
  "openai": "^6.36.0",
46
46
  "partial-json": "^0.1.7",
47
47
  "zod": "4.4.3"
@@ -5,7 +5,8 @@
5
5
  * `vib auth-broker status` (liveness checks). All endpoints except
6
6
  * `/v1/healthz` require a bearer token.
7
7
  */
8
- import { readSseEvents } from "@vib-rato/utils";
8
+ import { logger, readSseEvents } from "@vib-rato/utils";
9
+ import { AIR_REFUSAL_MESSAGE, isAirMode } from "@vib-rato/utils/air/mode";
9
10
  import type { ZodType, infer as zInfer } from "zod/v4";
10
11
  import type { AuthCredential } from "../auth-storage";
11
12
  import type { Provider } from "../types";
@@ -201,6 +202,13 @@ export class AuthBrokerClient {
201
202
  * to long-polling for the remainder of its lifetime.
202
203
  */
203
204
  async *openSnapshotStream(opts: { signal?: AbortSignal } = {}): AsyncGenerator<SnapshotStreamEvent> {
205
+ if (isAirMode()) {
206
+ logger.debug("airv: skipped auth-broker-snapshot-stream", {
207
+ feature: "auth-broker-snapshot-stream",
208
+ destination: "auth-broker",
209
+ });
210
+ throw new AuthBrokerError(AIR_REFUSAL_MESSAGE);
211
+ }
204
212
  const url = `${this.#baseUrl}/v1/snapshot/stream`;
205
213
  const headers: Record<string, string> = {
206
214
  Accept: "text/event-stream",
@@ -389,6 +397,13 @@ export class AuthBrokerClient {
389
397
  timeoutMs?: number;
390
398
  },
391
399
  ): Promise<Response> {
400
+ if (isAirMode()) {
401
+ logger.debug("airv: skipped auth-broker-request", {
402
+ feature: "auth-broker-request",
403
+ destination: "auth-broker",
404
+ });
405
+ throw new AuthBrokerError(AIR_REFUSAL_MESSAGE);
406
+ }
392
407
  const auth = opts.auth ?? true;
393
408
  const url = `${this.#baseUrl}${path}`;
394
409
  const headers: Record<string, string> = { Accept: "application/json", ...(opts.headers ?? {}) };
@@ -12,6 +12,7 @@ import * as crypto from "node:crypto";
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import { getAgentDbPath, logger } from "@vib-rato/utils";
15
+ import { isAirMode } from "@vib-rato/utils/air/mode";
15
16
  import { checkOpenCodexStatus } from "./providers/openai-opencodex-responses";
16
17
  import { getEnvApiKey } from "./stream";
17
18
  import type { Provider } from "./types";
@@ -4127,6 +4128,13 @@ export class AuthStorage {
4127
4128
  /** Disable provider/account/error logging for secret-safe control surfaces. */
4128
4129
  logDetails?: boolean;
4129
4130
  }): Promise<UsageReport[] | null> {
4131
+ if (isAirMode()) {
4132
+ logger.debug("airv: skipped auth-storage-usage-reports", {
4133
+ feature: "auth-storage-usage-reports",
4134
+ destination: "usage-api",
4135
+ });
4136
+ return [];
4137
+ }
4130
4138
  // Caller override > store-level hook > local per-credential fan-out.
4131
4139
  // `RemoteAuthCredentialStore` implements the store hook so a gateway
4132
4140
  // backed by a broker automatically routes usage to the broker without
@@ -6411,8 +6419,24 @@ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | und
6411
6419
  * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
6412
6420
  * without going through `AuthStorage`.
6413
6421
  */
6422
+ /**
6423
+ * `excludeProviders` removes those providers from every credential *read* at
6424
+ * the SQL level: the listing statements carry a `provider NOT IN (…)` clause,
6425
+ * so the `data` column of an excluded row is never fetched and its payload
6426
+ * never enters the process. Writes are untouched — this hides rows from a
6427
+ * reader, it does not make the store forget them.
6428
+ *
6429
+ * The air-gapped mode (`airv`) is the caller: hosted-provider credentials must
6430
+ * not be read at all, while local-endpoint keys live in the same database and
6431
+ * must keep working.
6432
+ */
6433
+ export interface SqliteAuthCredentialStoreOptions {
6434
+ excludeProviders?: readonly string[];
6435
+ }
6436
+
6414
6437
  export class SqliteAuthCredentialStore implements AuthCredentialStore {
6415
6438
  #db: Database;
6439
+ readonly #excludedProviders: readonly string[];
6416
6440
  #listActiveStmt: Statement;
6417
6441
  #listActiveByProviderStmt: Statement;
6418
6442
  #listAllStmt: Statement;
@@ -6432,21 +6456,35 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6432
6456
  #deleteExpiredCacheStmt: Statement;
6433
6457
  #closed = false;
6434
6458
 
6435
- constructor(db: Database) {
6459
+ constructor(db: Database, options: SqliteAuthCredentialStoreOptions = {}) {
6436
6460
  this.#db = db;
6437
6461
  this.#initializeSchema();
6462
+ this.#excludedProviders = Object.freeze(
6463
+ [...new Set((options.excludeProviders ?? []).map(provider => provider.trim().toLowerCase()))].filter(
6464
+ provider => provider.length > 0,
6465
+ ),
6466
+ );
6467
+ // One extra `AND provider NOT IN (…)` folded into the listing statements
6468
+ // when an exclusion is configured; with none, the SQL is byte-identical to
6469
+ // what it has always been.
6470
+ const notExcluded =
6471
+ this.#excludedProviders.length === 0
6472
+ ? ""
6473
+ : ` AND lower(provider) NOT IN (${this.#excludedProviders.map(() => "?").join(", ")})`;
6438
6474
 
6439
6475
  this.#listActiveStmt = this.#db.prepare(
6440
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
6476
+ `SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE disabled_cause IS NULL${notExcluded} ORDER BY id ASC`,
6441
6477
  );
6442
6478
  this.#listActiveByProviderStmt = this.#db.prepare(
6443
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
6479
+ `SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL${notExcluded} ORDER BY id ASC`,
6444
6480
  );
6445
6481
  this.#listAllStmt = this.#db.prepare(
6446
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials ORDER BY id ASC",
6482
+ `SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials${
6483
+ notExcluded === "" ? "" : ` WHERE 1 = 1${notExcluded}`
6484
+ } ORDER BY id ASC`,
6447
6485
  );
6448
6486
  this.#listAllByProviderStmt = this.#db.prepare(
6449
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
6487
+ `SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ?${notExcluded} ORDER BY id ASC`,
6450
6488
  );
6451
6489
  this.#listDisabledByProviderStmt = this.#db.prepare(
6452
6490
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
@@ -6481,7 +6519,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6481
6519
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
6482
6520
  }
6483
6521
 
6484
- static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
6522
+ static async open(
6523
+ dbPath: string = getAgentDbPath(),
6524
+ options: SqliteAuthCredentialStoreOptions = {},
6525
+ ): Promise<SqliteAuthCredentialStore> {
6485
6526
  const dir = path.dirname(dbPath);
6486
6527
  const dirExists = await fs
6487
6528
  .stat(dir)
@@ -6498,7 +6539,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6498
6539
  // Ignore chmod failures (e.g., Windows)
6499
6540
  }
6500
6541
 
6501
- return new SqliteAuthCredentialStore(db);
6542
+ return new SqliteAuthCredentialStore(db, options);
6502
6543
  }
6503
6544
 
6504
6545
  #initializeSchema(): void {
@@ -6722,11 +6763,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6722
6763
 
6723
6764
  // ─── AuthCredentialStore interface ──────────────────────────────────────
6724
6765
 
6766
+ /** True when reads must not see this provider's rows at all. */
6767
+ #isExcludedProvider(provider: string): boolean {
6768
+ return this.#excludedProviders.includes(provider.trim().toLowerCase());
6769
+ }
6770
+
6725
6771
  listAuthCredentials(provider?: string): StoredAuthCredential[] {
6772
+ if (provider !== undefined && this.#isExcludedProvider(provider)) return [];
6726
6773
  const rows =
6727
6774
  (provider
6728
- ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[])
6729
- : (this.#listActiveStmt.all() as AuthRow[])) ?? [];
6775
+ ? (this.#listActiveByProviderStmt.all(provider, ...this.#excludedProviders) as AuthRow[])
6776
+ : (this.#listActiveStmt.all(...this.#excludedProviders) as AuthRow[])) ?? [];
6730
6777
 
6731
6778
  const results: StoredAuthCredential[] = [];
6732
6779
  for (const row of rows) {
@@ -6738,10 +6785,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6738
6785
  }
6739
6786
 
6740
6787
  listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
6788
+ if (provider !== undefined && this.#isExcludedProvider(provider)) return [];
6741
6789
  const rows =
6742
6790
  provider === undefined
6743
- ? (this.#listAllStmt.all() as AuthRow[])
6744
- : (this.#listAllByProviderStmt.all(provider) as AuthRow[]);
6791
+ ? (this.#listAllStmt.all(...this.#excludedProviders) as AuthRow[])
6792
+ : (this.#listAllByProviderStmt.all(provider, ...this.#excludedProviders) as AuthRow[]);
6745
6793
  const results: CredentialInventoryRecord[] = [];
6746
6794
  for (const row of rows) {
6747
6795
  const credential = deserializeCredential(row);
@@ -6766,10 +6814,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6766
6814
  }
6767
6815
 
6768
6816
  listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[] {
6817
+ if (provider !== undefined && this.#isExcludedProvider(provider)) return [];
6769
6818
  const rows =
6770
6819
  provider === undefined
6771
- ? (this.#listAllStmt.all() as AuthRow[])
6772
- : (this.#listAllByProviderStmt.all(provider) as AuthRow[]);
6820
+ ? (this.#listAllStmt.all(...this.#excludedProviders) as AuthRow[])
6821
+ : (this.#listAllByProviderStmt.all(provider, ...this.#excludedProviders) as AuthRow[]);
6773
6822
  return rows.map(row => ({ id: row.id, provider: row.provider, expectedRevision: row.revision }));
6774
6823
  }
6775
6824
  removeAuthCredentialsHard(
@@ -1,4 +1,5 @@
1
- import { sanitizeText } from "@vib-rato/utils";
1
+ import { logger, sanitizeText } from "@vib-rato/utils";
2
+ import { isAirMode } from "@vib-rato/utils/air/mode";
2
3
  import { applyFinalCodexGpt56ContextCap } from "./context-cap-policy";
3
4
  import { insertModelCacheIfAbsent, readModelCache, updateModelCacheIfUnchanged, writeModelCache } from "./model-cache";
4
5
  import { isRetiredModel, isRetiredModelKey } from "./model-retirements";
@@ -178,6 +179,13 @@ async function resolveProviderModelsUncoalesced<TApi extends Api = Api, TModelsD
178
179
  options: ModelManagerOptions<TApi, TModelsDevPayload>,
179
180
  strategy: ModelRefreshStrategy = "online-if-uncached",
180
181
  ): Promise<ModelResolutionResult<TApi>> {
182
+ // airv only blocks the models.dev destination (see `fetchModelsDev` below),
183
+ // not this whole resolution: a provider's own `fetchDynamicModels` talks to
184
+ // its configured `baseUrl` (e.g. a local vLLM/Ollama gateway), which is a
185
+ // destination the EgressGuard and the provider's own boundary classification
186
+ // gate, not a feature this layer should suppress wholesale. Forcing
187
+ // `strategy = "offline"` here would also skip that fetcher, leaving airv
188
+ // users unable to discover local models without hand-writing every entry.
181
189
  const now = options.now ?? Date.now;
182
190
  const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
183
191
  const dbPath = options.cacheDbPath;
@@ -405,6 +413,13 @@ async function fetchModelsDev<TApi extends Api, TModelsDevPayload>(
405
413
  if (!options.modelsDev) {
406
414
  return null;
407
415
  }
416
+ if (isAirMode()) {
417
+ // airv blocks only the models.dev destination here; a provider's own
418
+ // `fetchDynamicModels` (its configured baseUrl) is unaffected — see the
419
+ // comment in `resolveProviderModelsUncoalesced` above.
420
+ logger.debug("airv: skipped models-dev-fetch", { feature: "models-dev-fetch", destination: "models.dev" });
421
+ return null;
422
+ }
408
423
 
409
424
  try {
410
425
  const payload = await options.modelsDev.fetch();
@@ -1,6 +1,7 @@
1
1
  // ============================================================================
2
2
  // High-level API
3
3
  // ============================================================================
4
+ import { AIR_REFUSAL_MESSAGE, isAirMode } from "@vib-rato/utils/air/mode";
4
5
  import type {
5
6
  OAuthCredentials,
6
7
  OAuthProvider,
@@ -332,6 +333,12 @@ export async function refreshOAuthToken(
332
333
  provider: OAuthProvider,
333
334
  credentials: OAuthCredentials,
334
335
  ): Promise<OAuthCredentials> {
336
+ // Rule (c): a refresh POSTs a stored credential to a provider token endpoint
337
+ // and runs without an explicit user command, so it is refused before the
338
+ // provider module is even imported. `@vib-rato/ai` cannot reach
339
+ // coding-agent's `AirRefusedError`, so the standard message is thrown
340
+ // directly; the string itself is the shared constant.
341
+ if (isAirMode()) throw new Error(AIR_REFUSAL_MESSAGE);
335
342
  if (!credentials) {
336
343
  throw new Error(`No OAuth credentials found for ${provider}`);
337
344
  }
@@ -476,6 +483,10 @@ export async function getOAuthApiKey(
476
483
  provider: OAuthProvider,
477
484
  credentials: Record<string, OAuthCredentials>,
478
485
  ): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> {
486
+ // Rule (c): handing back an OAuth access token exists only to authenticate a
487
+ // request to that provider's cloud endpoint. Refuse ahead of the lookup so no
488
+ // credential is read, not merely unused.
489
+ if (isAirMode()) throw new Error(AIR_REFUSAL_MESSAGE);
479
490
  let creds = credentials[provider];
480
491
  if (!creds) {
481
492
  return null;
@@ -1,5 +1,7 @@
1
1
  import * as net from "node:net";
2
2
  import * as tls from "node:tls";
3
+ import { classifyDestination } from "@vib-rato/utils/air/boundary";
4
+ import { AIR_REFUSAL_MESSAGE, airAllowedSuffixes, isAirMode } from "@vib-rato/utils/air/mode";
3
5
 
4
6
  export interface ProxyConnectOptions {
5
7
  signal?: AbortSignal;
@@ -248,6 +250,10 @@ function matchesNoProxy(hostname: string, port: number | undefined): boolean {
248
250
  }
249
251
 
250
252
  export function getProxyForUrl(provider: string, url: URL): string | undefined {
253
+ // airv: a proxy is by definition a third host, and this function already
254
+ // declines to proxy private targets, so in air-gapped mode a proxy is never
255
+ // the right answer. Refuse unconditionally rather than dial an env-supplied host.
256
+ if (isAirMode()) return undefined;
251
257
  const hostname = normalizeHost(url.hostname);
252
258
  if (!hostname || isPrivateOrLocalHost(hostname) || matchesNoProxy(hostname, effectiveUrlPort(url))) return undefined;
253
259
 
@@ -586,6 +592,18 @@ export function connectProxiedSocket(
586
592
  targetUrl: string,
587
593
  options: ProxyConnectOptions = {},
588
594
  ): Promise<tls.TLSSocket> {
595
+ // airv defense in depth. `getProxyForUrl` already returns undefined in
596
+ // air-gapped mode, so nothing should reach here; classify anyway, because
597
+ // these raw `net.connect` / `tls.connect` sockets never pass the fetch guard.
598
+ if (isAirMode()) {
599
+ const suffixes = airAllowedSuffixes();
600
+ const target = classifyDestination(targetUrl, suffixes);
601
+ const proxy = classifyDestination(proxyUrl, suffixes);
602
+ if (!target.allowed || !proxy.allowed) {
603
+ const reason = target.allowed ? (proxy as { reason: string }).reason : (target as { reason: string }).reason;
604
+ return Promise.reject(transportError(`${AIR_REFUSAL_MESSAGE} (${reason})`));
605
+ }
606
+ }
589
607
  const { promise, resolve, reject } = Promise.withResolvers<tls.TLSSocket>();
590
608
  const sockets = new Set<net.Socket>();
591
609
  const abortController = new AbortController();