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:
can1357
2026-07-02 01:51:09 +02:00
parent cc78244217
commit d5b26b6b97
5 changed files with 52 additions and 25 deletions
+1 -2
View File
@@ -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 (
+4
View File
@@ -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
+1
View File
@@ -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;
@@ -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 () => {