build(utils): relocated tls-fetch utility to the utilities package
- Extracted the `tls-fetch` implementation and tests from `@oh-my-pi/pi-ai` to `@oh-my-pi/pi-utils`. - Exported the `wrapFetchForExtraCa` and `withExtraCaFetch` utilities publicly from `@oh-my-pi/pi-utils`. - Introduced `ExtraCaError` to replace the AI-specific `ValidationError` for missing `NODE_EXTRA_CA_CERTS` paths. - Updated imports in `packages/ai/src/stream.ts` to consume the relocated utility.
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
|||||||
resolveWireModelId,
|
resolveWireModelId,
|
||||||
} from "@oh-my-pi/pi-catalog/model-thinking";
|
} from "@oh-my-pi/pi-catalog/model-thinking";
|
||||||
import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
|
import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
|
||||||
import { $env, $pickenv, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
import { $env, $pickenv, getConfigRootDir, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
|
||||||
import { getCustomApi } from "./api-registry";
|
import { getCustomApi } from "./api-registry";
|
||||||
import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry";
|
import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry";
|
||||||
import * as AIError from "./error";
|
import * as AIError from "./error";
|
||||||
@@ -75,7 +75,6 @@ import { wrapLeakedThinkingStream } from "./utils/leaked-thinking-stream";
|
|||||||
import { wrapFetchForProxy } from "./utils/proxy";
|
import { wrapFetchForProxy } from "./utils/proxy";
|
||||||
import { withRequestDebugFetch } from "./utils/request-debug";
|
import { withRequestDebugFetch } from "./utils/request-debug";
|
||||||
import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop";
|
import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop";
|
||||||
import { withExtraCaFetch } from "./utils/tls-fetch";
|
|
||||||
|
|
||||||
function isGoogleVertexAuthenticatedModel(model: Model<Api>): boolean {
|
function isGoogleVertexAuthenticatedModel(model: Model<Api>): boolean {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `wrapFetchForExtraCa` / `withExtraCaFetch` (moved from `@oh-my-pi/pi-ai` internals): a fetch wrapper that applies `NODE_EXTRA_CA_CERTS` to Bun's `RequestInit.tls.ca`, shared by provider streaming and catalog model discovery.
|
||||||
|
|
||||||
## [16.2.9] - 2026-06-30
|
## [16.2.9] - 2026-06-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export * from "./snowflake";
|
|||||||
export * from "./stream";
|
export * from "./stream";
|
||||||
export * from "./tab-spacing";
|
export * from "./tab-spacing";
|
||||||
export * from "./temp";
|
export * from "./temp";
|
||||||
|
export * from "./tls-fetch";
|
||||||
export * from "./type-guards";
|
export * from "./type-guards";
|
||||||
export * from "./which";
|
export * from "./which";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* `NODE_EXTRA_CA_CERTS` shim for the Bun fetch path used by every provider
|
* `NODE_EXTRA_CA_CERTS` shim for Bun's `fetch`.
|
||||||
* stream.
|
|
||||||
*
|
*
|
||||||
* Node's TLS layer honours `NODE_EXTRA_CA_CERTS` natively, but Bun's
|
* Node's TLS layer honours `NODE_EXTRA_CA_CERTS` natively, but Bun's
|
||||||
* `fetch` does not, and the OpenAI-compatible providers (`openai-responses`,
|
* `fetch` does not, and both the provider streams (`openai-responses`,
|
||||||
* `openai-completions`, `openai-codex-responses`, `ollama-chat`, ...) route
|
* `openai-completions`, `openai-codex-responses`, `ollama-chat`, ...) and
|
||||||
* every request through Bun's runtime. Without this wrapper, corporate
|
* catalog model discovery (`/models` probes) route every request through
|
||||||
* relays and private gateways behind a custom CA bundle fail with
|
* Bun's runtime. Without this wrapper, corporate relays and private
|
||||||
|
* gateways behind a custom CA bundle fail with
|
||||||
* `unknown certificate verification error` even when the env var is set.
|
* `unknown certificate verification error` even when the env var is set.
|
||||||
* (Foundry mTLS in {@link resolveFoundryTlsOptions} consumed the env var for
|
|
||||||
* the Anthropic path only — issue #3731.)
|
|
||||||
*
|
*
|
||||||
* The wrapper merges the resolved CA bundle into Bun's `RequestInit.tls.ca`.
|
* The wrapper merges the resolved CA bundle into Bun's `RequestInit.tls.ca`.
|
||||||
* Bun's `tls.ca` REPLACES the default trust store when set, so the wrapper
|
* Bun's `tls.ca` REPLACES the default trust store when set, so the wrapper
|
||||||
@@ -18,9 +16,29 @@
|
|||||||
*/
|
*/
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as tls from "node:tls";
|
import * as tls from "node:tls";
|
||||||
import { $env, isEnoent } from "@oh-my-pi/pi-utils";
|
import { $env } from "./env";
|
||||||
import * as AIError from "../error";
|
import { isEnoent } from "./fs-error";
|
||||||
import type { FetchImpl } from "../types";
|
|
||||||
|
/**
|
||||||
|
* `fetch`-compatible function. Accepts any callable matching the standard
|
||||||
|
* fetch signature; `preconnect` is optional because non-Bun runtimes
|
||||||
|
* (browsers, test mocks) won't expose it.
|
||||||
|
*/
|
||||||
|
export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) & {
|
||||||
|
preconnect?: typeof globalThis.fetch.preconnect;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `NODE_EXTRA_CA_CERTS` was set but unusable (path does not exist). This is
|
||||||
|
* a config/contract error, not a transient transport fault — it is never
|
||||||
|
* retried.
|
||||||
|
*/
|
||||||
|
export class ExtraCaError extends Error {
|
||||||
|
constructor(message: string, options?: { cause?: unknown }) {
|
||||||
|
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||||
|
this.name = "ExtraCaError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bun extension to `RequestInit` for the TLS options we touch. */
|
/** Bun extension to `RequestInit` for the TLS options we touch. */
|
||||||
type BunTlsOptions = {
|
type BunTlsOptions = {
|
||||||
@@ -41,7 +59,7 @@ type ExtraCaFetch = FetchImpl & { [EXTRA_CA_FETCH_MARKER]?: true };
|
|||||||
* Cached resolution of `NODE_EXTRA_CA_CERTS`. Keyed on the env value plus
|
* Cached resolution of `NODE_EXTRA_CA_CERTS`. Keyed on the env value plus
|
||||||
* the file mtime for path values so on-disk cert rotation (short-lived
|
* the file mtime for path values so on-disk cert rotation (short-lived
|
||||||
* corporate bundles) invalidates the cache instead of pinning the first
|
* corporate bundles) invalidates the cache instead of pinning the first
|
||||||
* read forever. Mirrors {@link foundryTlsOptionsCacheKey}.
|
* read forever.
|
||||||
*/
|
*/
|
||||||
let cacheKey: string | undefined;
|
let cacheKey: string | undefined;
|
||||||
let cacheValue: string | undefined;
|
let cacheValue: string | undefined;
|
||||||
@@ -56,7 +74,7 @@ let cacheValue: string | undefined;
|
|||||||
* shell exports.
|
* shell exports.
|
||||||
* - File path. Anything that does not contain a PEM header is treated as a
|
* - File path. Anything that does not contain a PEM header is treated as a
|
||||||
* path, matching Node's "extensionless filename is still a path" contract.
|
* path, matching Node's "extensionless filename is still a path" contract.
|
||||||
* `ENOENT` becomes {@link AIError.ValidationError}; other I/O errors bubble.
|
* `ENOENT` becomes {@link ExtraCaError}; other I/O errors bubble.
|
||||||
*/
|
*/
|
||||||
function resolveExtraCa(): string | undefined {
|
function resolveExtraCa(): string | undefined {
|
||||||
const raw = $env.NODE_EXTRA_CA_CERTS?.trim();
|
const raw = $env.NODE_EXTRA_CA_CERTS?.trim();
|
||||||
@@ -81,7 +99,7 @@ function resolveExtraCa(): string | undefined {
|
|||||||
cacheValue = fs.readFileSync(raw, "utf8");
|
cacheValue = fs.readFileSync(raw, "utf8");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isEnoent(error)) {
|
if (isEnoent(error)) {
|
||||||
throw new AIError.ValidationError(`NODE_EXTRA_CA_CERTS path does not exist: ${raw}`);
|
throw new ExtraCaError(`NODE_EXTRA_CA_CERTS path does not exist: ${raw}`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -101,7 +119,7 @@ export function __resetExtraCaCache(): void {
|
|||||||
* list, the system root store is included alongside the extra bundle —
|
* list, the system root store is included alongside the extra bundle —
|
||||||
* Bun's `tls.ca` replaces the default trust store, so omitting roots would
|
* Bun's `tls.ca` replaces the default trust store, so omitting roots would
|
||||||
* break every public host. When the caller already curated a list (e.g.
|
* break every public host. When the caller already curated a list (e.g.
|
||||||
* Anthropic Foundry's {@link resolveFoundryTlsOptions}, which already seeds
|
* Anthropic Foundry's mTLS options, which already seed
|
||||||
* `tls.rootCertificates`), only the extra CA is appended.
|
* `tls.rootCertificates`), only the extra CA is appended.
|
||||||
*/
|
*/
|
||||||
function withExtraCaInit(init: RequestInit | undefined, extraCa: string): RequestInit {
|
function withExtraCaInit(init: RequestInit | undefined, extraCa: string): RequestInit {
|
||||||
@@ -146,9 +164,10 @@ export function wrapFetchForExtraCa(fetchImpl: FetchImpl): FetchImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience for the stream-entry composition in `stream.ts`. Mirrors
|
* Convenience for options-bag composition (e.g. the stream-entry path in
|
||||||
* {@link withRequestDebugFetch} so the proxy/debug/extra-CA wrappers compose
|
* `@oh-my-pi/pi-ai`'s `stream.ts`, which mirrors `withRequestDebugFetch` so
|
||||||
* uniformly. No-op when the env var is unset.
|
* the proxy/debug/extra-CA wrappers compose uniformly). No-op when the env
|
||||||
|
* var is unset.
|
||||||
*/
|
*/
|
||||||
export function withExtraCaFetch<T extends { fetch?: FetchImpl } | undefined>(options: T): T {
|
export function withExtraCaFetch<T extends { fetch?: FetchImpl } | undefined>(options: T): T {
|
||||||
if (!$env.NODE_EXTRA_CA_CERTS?.trim()) return options;
|
if (!$env.NODE_EXTRA_CA_CERTS?.trim()) return options;
|
||||||
+9
-5
@@ -3,9 +3,13 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as tls from "node:tls";
|
import * as tls from "node:tls";
|
||||||
import * as AIError from "../../error";
|
import {
|
||||||
import type { FetchImpl } from "../../types";
|
__resetExtraCaCache,
|
||||||
import { __resetExtraCaCache, withExtraCaFetch, wrapFetchForExtraCa } from "../tls-fetch";
|
ExtraCaError,
|
||||||
|
type FetchImpl,
|
||||||
|
withExtraCaFetch,
|
||||||
|
wrapFetchForExtraCa,
|
||||||
|
} from "@oh-my-pi/pi-utils/tls-fetch";
|
||||||
|
|
||||||
const SAMPLE_PEM =
|
const SAMPLE_PEM =
|
||||||
"-----BEGIN CERTIFICATE-----\nMIIBkTCCATegAwIBAgIUF/sample/extra/ca/for/tests/1234567=\n-----END CERTIFICATE-----\n";
|
"-----BEGIN CERTIFICATE-----\nMIIBkTCCATegAwIBAgIUF/sample/extra/ca/for/tests/1234567=\n-----END CERTIFICATE-----\n";
|
||||||
@@ -128,12 +132,12 @@ describe("wrapFetchForExtraCa", () => {
|
|||||||
expect(ca).not.toContain(SAMPLE_PEM);
|
expect(ca).not.toContain(SAMPLE_PEM);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws ValidationError when the configured path does not exist", async () => {
|
it("throws ExtraCaError when the configured path does not exist", async () => {
|
||||||
Bun.env.NODE_EXTRA_CA_CERTS = path.join(tmpDir, "missing.pem");
|
Bun.env.NODE_EXTRA_CA_CERTS = path.join(tmpDir, "missing.pem");
|
||||||
|
|
||||||
const { fetchImpl } = makeRecordingFetch();
|
const { fetchImpl } = makeRecordingFetch();
|
||||||
const wrapped = wrapFetchForExtraCa(fetchImpl);
|
const wrapped = wrapFetchForExtraCa(fetchImpl);
|
||||||
await expect(wrapped("https://corp.example/v1")).rejects.toBeInstanceOf(AIError.ValidationError);
|
await expect(wrapped("https://corp.example/v1")).rejects.toBeInstanceOf(ExtraCaError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — wrapping a wrapped fetch returns the same reference", async () => {
|
it("is idempotent — wrapping a wrapped fetch returns the same reference", async () => {
|
||||||
Reference in New Issue
Block a user