@vib-rato/ai 0.17.3 → 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 +13 -0
- package/dist/types/auth-storage.d.ts +16 -2
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/types.d.ts +18 -0
- package/dist/types/utils/fallback-transport.d.ts +3 -2
- package/package.json +3 -3
- package/src/auth-broker/client.ts +16 -1
- package/src/auth-storage.ts +62 -13
- package/src/model-manager.ts +16 -1
- package/src/model-thinking.ts +12 -1
- package/src/models.json +25 -0
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/openai-codex/response-handler.ts +1 -1
- package/src/providers/openai-codex-responses.d.ts +6 -0
- package/src/providers/openai-codex-responses.ts +17 -2
- package/src/providers/pi-native-client.ts +24 -1
- package/src/types.d.ts +18 -0
- package/src/types.ts +19 -0
- package/src/utils/fallback-transport.d.ts +3 -2
- package/src/utils/fallback-transport.ts +42 -8
- package/src/utils/oauth/callback-server.ts +8 -1
- package/src/utils/oauth/index.ts +11 -0
- package/src/utils/proxy.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
11
|
+
## [0.18.0] - 2026-09-08
|
|
12
|
+
|
|
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`.
|
|
14
|
+
- OpenAI Codex HTTP failures with an empty response body and empty status text now include the HTTP status in the surfaced message instead of the opaque `Request failed`, while structured provider errors retain their existing messages and classification.
|
|
15
|
+
- OAuth callback responses now serialize provider-controlled result fields as safe JSON script data, preventing callback values from terminating the embedded state element while preserving exact JSON values and callback behavior.
|
|
16
|
+
- Bundled `anthropic/claude-fable-5-1` (Claude Fable 5.1): 1M context, 128K output, `anthropic-adaptive` thinking clamped to `xhigh` on the Messages API, matching the Fable 5 effort ladder exactly. The model is served only by Anthropic's first-party API and requires a Claude Code compatibility version of at least `2.1.251`; older fingerprints are answered with an HTTP 400 `claude_code_version_too_old`, which the `2.1.257` attribution bump clears. Verified live: `2.1.219` returns the 400, `2.1.257` returns 200, `low`/`high`/`xhigh` all stream, and adaptive thinking returns visible summarized content rather than signature-only billed thinking.
|
|
17
|
+
- OpenAI Codex WebSockets are now opt-in on Windows. Bun 1.4.0 can segfault in its Windows TLS WebSocket handshake, so the automatic model preference uses SSE on Windows while the explicit WebSocket setting remains available for operators who have a newer safe runtime.
|
|
5
18
|
## [0.17.3] - 2026-09-03
|
|
6
19
|
|
|
7
20
|
## [0.17.2] - 2026-09-03
|
|
@@ -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[];
|
|
@@ -107,7 +107,7 @@ export interface CpaToolAliasRestoreFailure {
|
|
|
107
107
|
*/
|
|
108
108
|
export declare function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined;
|
|
109
109
|
export declare function isCpaToolAliasRestoreFailure(error: unknown): boolean;
|
|
110
|
-
export declare const claudeCodeVersion = "2.1.
|
|
110
|
+
export declare const claudeCodeVersion = "2.1.257";
|
|
111
111
|
export declare const claudeCodeEntrypoint = "sdk-cli";
|
|
112
112
|
export declare const claudeToolPrefix: string;
|
|
113
113
|
export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
@@ -25,6 +25,12 @@ export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined,
|
|
|
25
25
|
export declare function formatCodexUserAgent(platform: string, release: string, arch: string): string;
|
|
26
26
|
export declare const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses">;
|
|
27
27
|
export declare function prewarmOpenAICodexResponses(model: Model<"openai-codex-responses">, options?: Pick<OpenAICodexResponsesOptions, "apiKey" | "headers" | "sessionId" | "signal" | "preferWebsockets" | "providerSessionState">): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Bun 1.4.0's Windows WebSocket client can segfault during TLS handshakes.
|
|
30
|
+
* Keep the model's websocket preference available on other platforms, while
|
|
31
|
+
* requiring an explicit opt-in on Windows until the bundled runtime is fixed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isCodexWebSocketSafeByDefault(platform?: NodeJS.Platform): boolean;
|
|
28
34
|
export interface OpenAICodexTransportDetails {
|
|
29
35
|
websocketPreferred: boolean;
|
|
30
36
|
lastTransport?: CodexTransport;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -997,6 +997,18 @@ export interface LongContextPricing {
|
|
|
997
997
|
threshold: number;
|
|
998
998
|
cost: ModelCost;
|
|
999
999
|
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Capability facts the serving endpoint advertised for a discovered model
|
|
1002
|
+
* (the `vibrato` object on its OpenAI-style models-list entry). Only fields
|
|
1003
|
+
* that describe what the model can do are carried: a hint can never redirect a
|
|
1004
|
+
* request or change its credentials.
|
|
1005
|
+
*/
|
|
1006
|
+
export interface DiscoveredModelHint {
|
|
1007
|
+
name?: string;
|
|
1008
|
+
reasoning?: boolean;
|
|
1009
|
+
thinking?: ThinkingConfig;
|
|
1010
|
+
compat?: Pick<OpenAICompat, "supportsReasoningEffort" | "reasoningContentField" | "thinkingFormat">;
|
|
1011
|
+
}
|
|
1000
1012
|
export interface Model<TApi extends Api = any> {
|
|
1001
1013
|
id: string;
|
|
1002
1014
|
name: string;
|
|
@@ -1022,6 +1034,12 @@ export interface Model<TApi extends Api = any> {
|
|
|
1022
1034
|
maxTokens: number;
|
|
1023
1035
|
/** Explicit models.yml/model-override authority; absent keeps the safe transport default. */
|
|
1024
1036
|
maxTokensSource?: ModelMaxTokensSource;
|
|
1037
|
+
/**
|
|
1038
|
+
* What the endpoint advertised about this model at discovery time. Kept on
|
|
1039
|
+
* the model so a cached discovery restores it; the registry applies it after
|
|
1040
|
+
* provider overrides and before the user's model overrides.
|
|
1041
|
+
*/
|
|
1042
|
+
discoveryHint?: DiscoveredModelHint;
|
|
1025
1043
|
headers?: Record<string, string>;
|
|
1026
1044
|
/**
|
|
1027
1045
|
* Streaming transport override. When `"pi-native"`, `streamSimple` routes
|
|
@@ -35,8 +35,9 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
|
35
35
|
* Structured facts from an upstream HTTP or transport failure. Retry decisions
|
|
36
36
|
* must use these facts rather than provider- or application-owned error text.
|
|
37
37
|
*
|
|
38
|
-
* `headers` is always a plain record limited to the retained retry
|
|
39
|
-
* entries: facts travel on persisted
|
|
38
|
+
* `headers` is always a plain record limited to the retained retry and
|
|
39
|
+
* gateway quota/queue signal entries: facts travel on persisted
|
|
40
|
+
* `AssistantMessage`s and through
|
|
40
41
|
* `structuredClone` snapshots (managed fallback attempt staging), so they must
|
|
41
42
|
* never carry a live `Headers` instance — cloning one throws `DataCloneError`
|
|
42
43
|
* ("The object can not be cloned.") and masks the real provider failure.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@vib-rato/ai",
|
|
4
|
-
"version": "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.
|
|
44
|
-
"@vib-rato/utils": "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 ?? {}) };
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
package/src/model-manager.ts
CHANGED
|
@@ -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();
|
package/src/model-thinking.ts
CHANGED
|
@@ -230,7 +230,6 @@ export function modelSupportsReasoningControl<TApi extends Api>(
|
|
|
230
230
|
resolvedBaseUrl?: string,
|
|
231
231
|
): boolean {
|
|
232
232
|
if (!model.reasoning) return false;
|
|
233
|
-
|
|
234
233
|
if (model.api === "openai-completions") {
|
|
235
234
|
const completionsModel = model as ApiModel<"openai-completions">;
|
|
236
235
|
const explicitSupport = completionsModel.compat?.supportsReasoningEffort;
|
|
@@ -346,6 +345,18 @@ export function clampThinkingLevelForModel<TApi extends Api>(
|
|
|
346
345
|
if (!modelSupportsReasoningControl(model) || requested === undefined) {
|
|
347
346
|
return undefined;
|
|
348
347
|
}
|
|
348
|
+
if (model.api === "anthropic-messages" && model.provider === "anthropic") {
|
|
349
|
+
const baseUrl = model.baseUrl;
|
|
350
|
+
try {
|
|
351
|
+
const url = new URL(baseUrl || "https://api.anthropic.com");
|
|
352
|
+
if (url.protocol !== "https:" || url.hostname !== "api.anthropic.com") return undefined;
|
|
353
|
+
} catch {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!model.thinking) {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
349
360
|
|
|
350
361
|
const levels = getSupportedEfforts(model);
|
|
351
362
|
if (levels.includes(requested)) {
|
package/src/models.json
CHANGED
|
@@ -3853,6 +3853,31 @@
|
|
|
3853
3853
|
"maxLevel": "xhigh"
|
|
3854
3854
|
}
|
|
3855
3855
|
},
|
|
3856
|
+
"claude-fable-5-1": {
|
|
3857
|
+
"id": "claude-fable-5-1",
|
|
3858
|
+
"name": "Anthropic Fable 5.1",
|
|
3859
|
+
"api": "anthropic-messages",
|
|
3860
|
+
"provider": "anthropic",
|
|
3861
|
+
"baseUrl": "https://api.anthropic.com",
|
|
3862
|
+
"reasoning": true,
|
|
3863
|
+
"input": [
|
|
3864
|
+
"text",
|
|
3865
|
+
"image"
|
|
3866
|
+
],
|
|
3867
|
+
"cost": {
|
|
3868
|
+
"input": 10,
|
|
3869
|
+
"output": 50,
|
|
3870
|
+
"cacheRead": 0.25,
|
|
3871
|
+
"cacheWrite": 12.5
|
|
3872
|
+
},
|
|
3873
|
+
"contextWindow": 1000000,
|
|
3874
|
+
"maxTokens": 128000,
|
|
3875
|
+
"thinking": {
|
|
3876
|
+
"mode": "anthropic-adaptive",
|
|
3877
|
+
"minLevel": "minimal",
|
|
3878
|
+
"maxLevel": "xhigh"
|
|
3879
|
+
}
|
|
3880
|
+
},
|
|
3856
3881
|
"claude-haiku-4-5": {
|
|
3857
3882
|
"id": "claude-haiku-4-5",
|
|
3858
3883
|
"name": "Anthropic Haiku 4.5 (latest)",
|
|
@@ -107,7 +107,7 @@ export interface CpaToolAliasRestoreFailure {
|
|
|
107
107
|
*/
|
|
108
108
|
export declare function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined;
|
|
109
109
|
export declare function isCpaToolAliasRestoreFailure(error: unknown): boolean;
|
|
110
|
-
export declare const claudeCodeVersion = "2.1.
|
|
110
|
+
export declare const claudeCodeVersion = "2.1.257";
|
|
111
111
|
export declare const claudeCodeEntrypoint = "sdk-cli";
|
|
112
112
|
export declare const claudeToolPrefix: string;
|
|
113
113
|
export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
@@ -828,7 +828,7 @@ function getCacheControl(
|
|
|
828
828
|
}
|
|
829
829
|
|
|
830
830
|
// Stealth mode: Mimic Anthropic Code headers and tool prefixing.
|
|
831
|
-
export const claudeCodeVersion = "2.1.
|
|
831
|
+
export const claudeCodeVersion = "2.1.257";
|
|
832
832
|
export const claudeCodeEntrypoint = "sdk-cli";
|
|
833
833
|
export const claudeToolPrefix: string = "proxy_";
|
|
834
834
|
export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
@@ -25,7 +25,7 @@ const REQUEST_BLOCKED_MESSAGE_RE = /^\s*request blocked\b/i;
|
|
|
25
25
|
|
|
26
26
|
export async function parseCodexError(response: Response): Promise<CodexErrorInfo> {
|
|
27
27
|
const raw = await response.text();
|
|
28
|
-
let message = raw || response.statusText ||
|
|
28
|
+
let message = raw || response.statusText || `Codex request failed (HTTP ${response.status})`;
|
|
29
29
|
let friendlyMessage: string | undefined;
|
|
30
30
|
let rateLimits: CodexRateLimits | undefined;
|
|
31
31
|
let code: string | undefined;
|
|
@@ -25,6 +25,12 @@ export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined,
|
|
|
25
25
|
export declare function formatCodexUserAgent(platform: string, release: string, arch: string): string;
|
|
26
26
|
export declare const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses">;
|
|
27
27
|
export declare function prewarmOpenAICodexResponses(model: Model<"openai-codex-responses">, options?: Pick<OpenAICodexResponsesOptions, "apiKey" | "headers" | "sessionId" | "signal" | "preferWebsockets" | "providerSessionState">): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Bun 1.4.0's Windows WebSocket client can segfault during TLS handshakes.
|
|
30
|
+
* Keep the model's websocket preference available on other platforms, while
|
|
31
|
+
* requiring an explicit opt-in on Windows until the bundled runtime is fixed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isCodexWebSocketSafeByDefault(platform?: NodeJS.Platform): boolean;
|
|
28
34
|
export interface OpenAICodexTransportDetails {
|
|
29
35
|
websocketPreferred: boolean;
|
|
30
36
|
lastTransport?: CodexTransport;
|
|
@@ -2307,6 +2307,15 @@ function recordCodexWebSocketFailure(state: CodexWebSocketSessionState, activate
|
|
|
2307
2307
|
}
|
|
2308
2308
|
}
|
|
2309
2309
|
|
|
2310
|
+
/**
|
|
2311
|
+
* Bun 1.4.0's Windows WebSocket client can segfault during TLS handshakes.
|
|
2312
|
+
* Keep the model's websocket preference available on other platforms, while
|
|
2313
|
+
* requiring an explicit opt-in on Windows until the bundled runtime is fixed.
|
|
2314
|
+
*/
|
|
2315
|
+
export function isCodexWebSocketSafeByDefault(platform: NodeJS.Platform = process.platform): boolean {
|
|
2316
|
+
return platform !== "win32";
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2310
2319
|
function shouldUseCodexWebSocket(
|
|
2311
2320
|
model: Model<"openai-codex-responses">,
|
|
2312
2321
|
state: CodexWebSocketSessionState | undefined,
|
|
@@ -2314,7 +2323,11 @@ function shouldUseCodexWebSocket(
|
|
|
2314
2323
|
): boolean {
|
|
2315
2324
|
if (!state || state.disableWebsocket) return false;
|
|
2316
2325
|
if (preferWebsockets === false) return false;
|
|
2317
|
-
return
|
|
2326
|
+
return (
|
|
2327
|
+
isCodexWebSocketEnvEnabled() ||
|
|
2328
|
+
preferWebsockets === true ||
|
|
2329
|
+
(isCodexWebSocketSafeByDefault() && model.preferWebsockets === true)
|
|
2330
|
+
);
|
|
2318
2331
|
}
|
|
2319
2332
|
|
|
2320
2333
|
export interface OpenAICodexTransportDetails {
|
|
@@ -2372,7 +2385,9 @@ export function getOpenAICodexTransportDetails(
|
|
|
2372
2385
|
const websocketPreferred =
|
|
2373
2386
|
options?.preferWebsockets === false
|
|
2374
2387
|
? false
|
|
2375
|
-
: isCodexWebSocketEnvEnabled() ||
|
|
2388
|
+
: isCodexWebSocketEnvEnabled() ||
|
|
2389
|
+
options?.preferWebsockets === true ||
|
|
2390
|
+
(isCodexWebSocketSafeByDefault() && model.preferWebsockets === true);
|
|
2376
2391
|
const state = getCodexWebSocketStateForPublicSession(model, options);
|
|
2377
2392
|
|
|
2378
2393
|
return {
|
|
@@ -49,6 +49,29 @@ const NON_WIRE_KEYS = new Set<keyof SimpleStreamOptions>([
|
|
|
49
49
|
"fallbackAttempt",
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Project the caller's {@link Context} onto the wire schema. Runtime tool
|
|
54
|
+
* objects routinely carry harness-only state (runners, session managers,
|
|
55
|
+
* fs-stat BigInts) that must never be serialized: BigInt fields make
|
|
56
|
+
* `JSON.stringify` throw outright, and the rest is dead weight the gateway
|
|
57
|
+
* re-derives from its own tool registry. Only the protocol-meaningful,
|
|
58
|
+
* JSON-safe `Tool` fields cross the wire.
|
|
59
|
+
*/
|
|
60
|
+
function buildWireContext(context: Context): Context {
|
|
61
|
+
if (!context.tools || context.tools.length === 0) return context;
|
|
62
|
+
return {
|
|
63
|
+
...context,
|
|
64
|
+
tools: context.tools.map(tool => ({
|
|
65
|
+
name: tool.name,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
parameters: tool.parameters,
|
|
68
|
+
...(tool.strict !== undefined ? { strict: tool.strict } : {}),
|
|
69
|
+
...(tool.customFormat !== undefined ? { customFormat: tool.customFormat } : {}),
|
|
70
|
+
...(tool.customWireName !== undefined ? { customWireName: tool.customWireName } : {}),
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
function buildWireOptions(options: SimpleStreamOptions | undefined): Record<string, unknown> {
|
|
53
76
|
if (!options) return {};
|
|
54
77
|
const wire: Record<string, unknown> = {};
|
|
@@ -166,7 +189,7 @@ export function streamPiNative<TApi extends Api>(
|
|
|
166
189
|
const headers = buildHeaders(model as Model<Api>, options?.apiKey);
|
|
167
190
|
const body = JSON.stringify({
|
|
168
191
|
modelId: model.id,
|
|
169
|
-
context,
|
|
192
|
+
context: buildWireContext(context),
|
|
170
193
|
options: buildWireOptions(options),
|
|
171
194
|
stream: true,
|
|
172
195
|
});
|
package/src/types.d.ts
CHANGED
|
@@ -997,6 +997,18 @@ export interface LongContextPricing {
|
|
|
997
997
|
threshold: number;
|
|
998
998
|
cost: ModelCost;
|
|
999
999
|
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Capability facts the serving endpoint advertised for a discovered model
|
|
1002
|
+
* (the `vibrato` object on its OpenAI-style models-list entry). Only fields
|
|
1003
|
+
* that describe what the model can do are carried: a hint can never redirect a
|
|
1004
|
+
* request or change its credentials.
|
|
1005
|
+
*/
|
|
1006
|
+
export interface DiscoveredModelHint {
|
|
1007
|
+
name?: string;
|
|
1008
|
+
reasoning?: boolean;
|
|
1009
|
+
thinking?: ThinkingConfig;
|
|
1010
|
+
compat?: Pick<OpenAICompat, "supportsReasoningEffort" | "reasoningContentField" | "thinkingFormat">;
|
|
1011
|
+
}
|
|
1000
1012
|
export interface Model<TApi extends Api = any> {
|
|
1001
1013
|
id: string;
|
|
1002
1014
|
name: string;
|
|
@@ -1022,6 +1034,12 @@ export interface Model<TApi extends Api = any> {
|
|
|
1022
1034
|
maxTokens: number;
|
|
1023
1035
|
/** Explicit models.yml/model-override authority; absent keeps the safe transport default. */
|
|
1024
1036
|
maxTokensSource?: ModelMaxTokensSource;
|
|
1037
|
+
/**
|
|
1038
|
+
* What the endpoint advertised about this model at discovery time. Kept on
|
|
1039
|
+
* the model so a cached discovery restores it; the registry applies it after
|
|
1040
|
+
* provider overrides and before the user's model overrides.
|
|
1041
|
+
*/
|
|
1042
|
+
discoveryHint?: DiscoveredModelHint;
|
|
1025
1043
|
headers?: Record<string, string>;
|
|
1026
1044
|
/**
|
|
1027
1045
|
* Streaming transport override. When `"pi-native"`, `streamSimple` routes
|
package/src/types.ts
CHANGED
|
@@ -1213,6 +1213,19 @@ export interface LongContextPricing {
|
|
|
1213
1213
|
cost: ModelCost;
|
|
1214
1214
|
}
|
|
1215
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Capability facts the serving endpoint advertised for a discovered model
|
|
1218
|
+
* (the `vibrato` object on its OpenAI-style models-list entry). Only fields
|
|
1219
|
+
* that describe what the model can do are carried: a hint can never redirect a
|
|
1220
|
+
* request or change its credentials.
|
|
1221
|
+
*/
|
|
1222
|
+
export interface DiscoveredModelHint {
|
|
1223
|
+
name?: string;
|
|
1224
|
+
reasoning?: boolean;
|
|
1225
|
+
thinking?: ThinkingConfig;
|
|
1226
|
+
compat?: Pick<OpenAICompat, "supportsReasoningEffort" | "reasoningContentField" | "thinkingFormat">;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1216
1229
|
export interface Model<TApi extends Api = any> {
|
|
1217
1230
|
id: string;
|
|
1218
1231
|
name: string;
|
|
@@ -1238,6 +1251,12 @@ export interface Model<TApi extends Api = any> {
|
|
|
1238
1251
|
maxTokens: number;
|
|
1239
1252
|
/** Explicit models.yml/model-override authority; absent keeps the safe transport default. */
|
|
1240
1253
|
maxTokensSource?: ModelMaxTokensSource;
|
|
1254
|
+
/**
|
|
1255
|
+
* What the endpoint advertised about this model at discovery time. Kept on
|
|
1256
|
+
* the model so a cached discovery restores it; the registry applies it after
|
|
1257
|
+
* provider overrides and before the user's model overrides.
|
|
1258
|
+
*/
|
|
1259
|
+
discoveryHint?: DiscoveredModelHint;
|
|
1241
1260
|
headers?: Record<string, string>;
|
|
1242
1261
|
/**
|
|
1243
1262
|
* Streaming transport override. When `"pi-native"`, `streamSimple` routes
|
|
@@ -35,8 +35,9 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
|
35
35
|
* Structured facts from an upstream HTTP or transport failure. Retry decisions
|
|
36
36
|
* must use these facts rather than provider- or application-owned error text.
|
|
37
37
|
*
|
|
38
|
-
* `headers` is always a plain record limited to the retained retry
|
|
39
|
-
* entries: facts travel on persisted
|
|
38
|
+
* `headers` is always a plain record limited to the retained retry and
|
|
39
|
+
* gateway quota/queue signal entries: facts travel on persisted
|
|
40
|
+
* `AssistantMessage`s and through
|
|
40
41
|
* `structuredClone` snapshots (managed fallback attempt staging), so they must
|
|
41
42
|
* never carry a live `Headers` instance — cloning one throws `DataCloneError`
|
|
42
43
|
* ("The object can not be cloned.") and masks the real provider failure.
|
|
@@ -40,8 +40,9 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
|
40
40
|
* Structured facts from an upstream HTTP or transport failure. Retry decisions
|
|
41
41
|
* must use these facts rather than provider- or application-owned error text.
|
|
42
42
|
*
|
|
43
|
-
* `headers` is always a plain record limited to the retained retry
|
|
44
|
-
* entries: facts travel on persisted
|
|
43
|
+
* `headers` is always a plain record limited to the retained retry and
|
|
44
|
+
* gateway quota/queue signal entries: facts travel on persisted
|
|
45
|
+
* `AssistantMessage`s and through
|
|
45
46
|
* `structuredClone` snapshots (managed fallback attempt staging), so they must
|
|
46
47
|
* never carry a live `Headers` instance — cloning one throws `DataCloneError`
|
|
47
48
|
* ("The object can not be cloned.") and masks the real provider failure.
|
|
@@ -147,14 +148,29 @@ function stringValue(value: unknown): string | undefined {
|
|
|
147
148
|
return typeof value === "string" ? value : undefined;
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Retry-signal headers retained on transport facts; everything else is
|
|
153
|
+
* dropped. The `x-vug-*` entries are the Vibrato Usage Gateway's quota and
|
|
154
|
+
* queue-depth signals; their values are preserved as raw strings and
|
|
155
|
+
* interpreted by consumers, not by this module.
|
|
156
|
+
*/
|
|
157
|
+
const RETAINED_TRANSPORT_HEADERS = [
|
|
158
|
+
"retry-after",
|
|
159
|
+
"retry-after-ms",
|
|
160
|
+
"x-vug-daily-limit",
|
|
161
|
+
"x-vug-daily-used",
|
|
162
|
+
"x-vug-daily-remaining",
|
|
163
|
+
"x-vug-daily-reset",
|
|
164
|
+
"x-vug-queue-depth",
|
|
165
|
+
"x-vug-inflight",
|
|
166
|
+
"x-vug-queued-ms",
|
|
167
|
+
] as const;
|
|
152
168
|
|
|
153
169
|
const RETAINED_TRANSPORT_HEADER_SET: ReadonlySet<string> = new Set(RETAINED_TRANSPORT_HEADERS);
|
|
154
170
|
|
|
155
171
|
/**
|
|
156
|
-
* Reduce transport headers to the retained retry
|
|
157
|
-
* record, so facts stay structured-cloneable and JSON-serializable and never
|
|
172
|
+
* Reduce transport headers to the retained retry and gateway quota/queue
|
|
173
|
+
* signal entries in a plain record, so facts stay structured-cloneable and JSON-serializable and never
|
|
158
174
|
* persist arbitrary response headers into session files.
|
|
159
175
|
*
|
|
160
176
|
* Exception-safe by contract: inspection uses only `Headers.get()` results
|
|
@@ -276,9 +292,22 @@ export function transportFailureFacts(
|
|
|
276
292
|
};
|
|
277
293
|
}
|
|
278
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Exception-safe by contract: `new Headers(record)` throws on an invalid
|
|
297
|
+
* header value (e.g. a CRLF in a gateway-supplied timestamp like
|
|
298
|
+
* `x-vug-daily-reset`), and the retained record is otherwise free-form
|
|
299
|
+
* provider input. A throw here falls through to `undefined` — the same
|
|
300
|
+
* "no retry hint" result as if no headers were retained at all — instead of
|
|
301
|
+
* propagating a TypeError out of classification.
|
|
302
|
+
*/
|
|
279
303
|
function headersOf(headers: TransportHeaders | undefined): Headers | undefined {
|
|
280
304
|
if (headers instanceof Headers) return headers;
|
|
281
|
-
|
|
305
|
+
if (!headers) return undefined;
|
|
306
|
+
try {
|
|
307
|
+
return new Headers(headers as Record<string, string>);
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
282
311
|
}
|
|
283
312
|
|
|
284
313
|
function parseRetryAfterSeconds(value: string | null, now = Date.now()): number | undefined {
|
|
@@ -305,7 +334,12 @@ function isQuotaCode(code: string | undefined): boolean {
|
|
|
305
334
|
code === "quota_exhausted" ||
|
|
306
335
|
code === "usage_limit_reached" ||
|
|
307
336
|
code === "usage_not_included" ||
|
|
308
|
-
code === "out_of_credits"
|
|
337
|
+
code === "out_of_credits" ||
|
|
338
|
+
// Vibrato Usage Gateway daily-limit code: a 429 with this code is a
|
|
339
|
+
// quota exhaustion, not an ordinary rate limit, even though the
|
|
340
|
+
// gateway's retry-after window can be many hours long. A code-less
|
|
341
|
+
// 429 from an older gateway still falls through to `rate_limit`.
|
|
342
|
+
code === "daily_token_limit"
|
|
309
343
|
);
|
|
310
344
|
}
|
|
311
345
|
|
|
@@ -19,6 +19,13 @@ const DEFAULT_TIMEOUT = 300_000;
|
|
|
19
19
|
const DEFAULT_HOSTNAME = "localhost";
|
|
20
20
|
const CALLBACK_PATH = "/callback";
|
|
21
21
|
|
|
22
|
+
function serializeScriptData(value: unknown): string {
|
|
23
|
+
return JSON.stringify(value)
|
|
24
|
+
.replaceAll("<", "\\u003c")
|
|
25
|
+
.replaceAll("\u2028", "\\u2028")
|
|
26
|
+
.replaceAll("\u2029", "\\u2029");
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
export type CallbackResult = { code: string; state: string };
|
|
23
30
|
|
|
24
31
|
export interface OAuthCallbackFlowOptions {
|
|
@@ -259,7 +266,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
259
266
|
});
|
|
260
267
|
|
|
261
268
|
return new Response(
|
|
262
|
-
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__",
|
|
269
|
+
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", () => serializeScriptData(resultState)),
|
|
263
270
|
{
|
|
264
271
|
status: resultState.ok ? 200 : 500,
|
|
265
272
|
headers: { "Content-Type": "text/html" },
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -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;
|
package/src/utils/proxy.ts
CHANGED
|
@@ -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();
|