424 lines
16 KiB
TypeScript
Executable File
424 lines
16 KiB
TypeScript
Executable File
import { z } from "zod";
|
|
|
|
// Control-plane configuration contracts. Shared verbatim by the gateway config
|
|
// service and the control UI so both fail at compile time when they drift.
|
|
|
|
export const ProviderTypeSchema = z.enum([
|
|
"openai",
|
|
"anthropic",
|
|
"azure",
|
|
"gemini",
|
|
"openrouter",
|
|
// Wave-3 breadth: OpenAI-wire-compatible vendors served by the OpenAI
|
|
// adapter with vendor base URLs…
|
|
"groq",
|
|
"mistral",
|
|
"ollama",
|
|
"xai",
|
|
"perplexity",
|
|
"cerebras",
|
|
"nebius",
|
|
"sgl",
|
|
"parasail",
|
|
"huggingface",
|
|
// …and native adapters.
|
|
"cohere",
|
|
"bedrock",
|
|
"vertex",
|
|
"elevenlabs",
|
|
// Generic env-configurable endpoints (user-supplied base URL).
|
|
"openai-compatible",
|
|
"anthropic-compatible",
|
|
"lmstudio",
|
|
]);
|
|
export type ProviderType = z.infer<typeof ProviderTypeSchema>;
|
|
|
|
/**
|
|
* Per-provider configuration groups backing the reference's 6-tab provider
|
|
* panel (Network / Proxy / Performance / Governance / Beta headers /
|
|
* Debugging). Every group and every field is OPTIONAL and additive: provider
|
|
* configs written before these existed still parse unchanged.
|
|
*
|
|
* Fields marked "persisted only (enforced:false)" are stored and surfaced for
|
|
* the UI but not yet acted on — see docs in admin/manager and the delivery
|
|
* table. Flags must never lie about behavior, so anything needing deep
|
|
* transport plumbing is explicitly NOT wired yet.
|
|
*/
|
|
|
|
/** One operator-supplied header added to every upstream request. */
|
|
export const ProviderExtraHeaderSchema = z.object({
|
|
name: z.string().min(1),
|
|
value: z.string(),
|
|
});
|
|
export type ProviderExtraHeader = z.infer<typeof ProviderExtraHeaderSchema>;
|
|
|
|
/** Browser-safe header metadata. Header values can carry credentials, so the
|
|
* control plane exposes only the name and whether a value is configured. */
|
|
export const ProviderExtraHeaderPublicSchema = ProviderExtraHeaderSchema.omit({
|
|
value: true,
|
|
}).extend({
|
|
hasValue: z.boolean(),
|
|
});
|
|
export type ProviderExtraHeaderPublic = z.infer<
|
|
typeof ProviderExtraHeaderPublicSchema
|
|
>;
|
|
|
|
/** Network tab. maxRetries/initialBackoffMs/maxBackoffMs + extraHeaders are
|
|
* honored via the shared provider client; the rest are persisted only. */
|
|
export const ProviderNetworkConfigSchema = z.object({
|
|
/** Honored: bounds request establishment + non-stream body reads via the
|
|
* shared provider client (else env FROSTY_HTTP_TIMEOUT_MS, default 120s; 0
|
|
* disables). Never total-caps an in-progress SSE stream. */
|
|
timeoutSec: z.number().positive().optional(),
|
|
/** Honored: inter-chunk IDLE timeout applied to SSE streams (reset per chunk),
|
|
* never a total cap. Absent = stream handed back untouched. */
|
|
streamIdleTimeoutSec: z.number().positive().optional(),
|
|
/** Honored: maps onto the provider client's retry count. */
|
|
maxRetries: z.number().int().nonnegative().optional(),
|
|
/** Honored: initial retry backoff (ms). */
|
|
initialBackoffMs: z.number().int().nonnegative().optional(),
|
|
/** Honored: maximum retry backoff (ms). */
|
|
maxBackoffMs: z.number().int().nonnegative().optional(),
|
|
/** Persisted only (enforced:false): Deno fetch exposes no per-host pool cap. */
|
|
maxConnectionsPerHost: z.number().int().positive().optional(),
|
|
/** Persisted only (enforced:false): needs an HTTP/2-only transport client. */
|
|
enforceHttp2: z.boolean().optional(),
|
|
/** Honored: added to every upstream request (adapter-set headers win). */
|
|
extraHeaders: z.array(ProviderExtraHeaderSchema).optional(),
|
|
/** Persisted only (enforced:false): needs a TLS-relaxed transport client. */
|
|
skipTlsVerify: z.boolean().optional(),
|
|
/** Honored: loaded as a custom CA via Deno.createHttpClient({caCerts}) on the
|
|
* provider client. Treated as a secret in redacted views (see hasCaCert). */
|
|
caCertPem: z.string().optional(),
|
|
});
|
|
export type ProviderNetworkConfig = z.infer<typeof ProviderNetworkConfigSchema>;
|
|
|
|
const NoProxyPatternSchema = z.string().min(1).max(255).refine(
|
|
(value) =>
|
|
value === "*" || /^(?:\*\.|\.)?[A-Za-z0-9][A-Za-z0-9.-]*$/.test(value),
|
|
"No-proxy entries must be *, an exact host, .suffix, or *.suffix.",
|
|
);
|
|
|
|
/** Proxy tab. proxyUrl stays top-level (backward-compat); these are additive.
|
|
* proxyUsername/proxyPassword are honored via the proxy client's basic auth
|
|
* when a proxyUrl is set. proxyPassword and noProxy rules are redacted from
|
|
* browser responses (see hasProxyPassword/noProxyCount). */
|
|
export const ProviderProxyConfigSchema = z.object({
|
|
/** Persisted only (enforced:false): the proxy protocol is taken from the
|
|
* proxyUrl scheme; this is advisory. */
|
|
proxyType: z.enum(["http", "https", "socks5"]).optional(),
|
|
proxyUsername: z.string().optional(),
|
|
proxyPassword: z.string().optional(),
|
|
/** Honored: provider-specific bypass rules override the global rules. */
|
|
noProxy: z.array(NoProxyPatternSchema).max(128).optional(),
|
|
});
|
|
export type ProviderProxyConfig = z.infer<typeof ProviderProxyConfigSchema>;
|
|
|
|
/**
|
|
* A gateway-wide default for provider egress only. The proxy URL deliberately
|
|
* rejects embedded userinfo: credentials have dedicated write-only fields, so
|
|
* a browser response, diagnostic, or URL parser can never accidentally expose
|
|
* them. Individual provider proxy settings override this default as a unit.
|
|
*/
|
|
const GlobalProxyUrlSchema = z.string().min(1).max(2048).superRefine(
|
|
(value, ctx) => {
|
|
try {
|
|
const url = new URL(value);
|
|
if (
|
|
!(["http:", "https:", "socks5:"] as string[]).includes(url.protocol)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Proxy URL must use http:, https:, or socks5:.",
|
|
});
|
|
}
|
|
if (!url.hostname) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Proxy URL must include a host.",
|
|
});
|
|
}
|
|
if (url.username || url.password) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message:
|
|
"Proxy URL must not contain credentials; use proxyUsername and proxyPassword.",
|
|
});
|
|
}
|
|
if (url.hash) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Proxy URL must not include a fragment.",
|
|
});
|
|
}
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Proxy URL must be an absolute URL.",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
export const GlobalProxyConfigSchema = z.object({
|
|
proxyUrl: GlobalProxyUrlSchema,
|
|
proxyUsername: z.string().min(1).max(256).optional(),
|
|
proxyPassword: z.string().min(1).max(1024).optional(),
|
|
noProxy: z.array(NoProxyPatternSchema).max(128).default([]),
|
|
}).superRefine((value, ctx) => {
|
|
if (value.proxyPassword !== undefined && value.proxyUsername === undefined) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["proxyUsername"],
|
|
message: "proxyUsername is required when proxyPassword is set.",
|
|
});
|
|
}
|
|
});
|
|
export type GlobalProxyConfig = z.infer<typeof GlobalProxyConfigSchema>;
|
|
|
|
/** Browser-safe status for the global proxy: no URL, credentials, or bypass
|
|
* rules are returned because all can reveal internal infrastructure. */
|
|
export const GlobalProxyPublicSchema = z.object({
|
|
enabled: z.boolean(),
|
|
proxyType: z.enum(["http", "https", "socks5"]).optional(),
|
|
hasCredentials: z.boolean(),
|
|
noProxyCount: z.number().int().nonnegative(),
|
|
});
|
|
export type GlobalProxyPublic = z.infer<typeof GlobalProxyPublicSchema>;
|
|
|
|
export function redactGlobalProxy(
|
|
config: GlobalProxyConfig | undefined,
|
|
): GlobalProxyPublic {
|
|
if (!config) {
|
|
return { enabled: false, hasCredentials: false, noProxyCount: 0 };
|
|
}
|
|
return {
|
|
enabled: true,
|
|
proxyType: new URL(config.proxyUrl).protocol.slice(0, -1) as
|
|
| "http"
|
|
| "https"
|
|
| "socks5",
|
|
hasCredentials: config.proxyUsername !== undefined,
|
|
noProxyCount: config.noProxy.length,
|
|
};
|
|
}
|
|
|
|
/** Performance tab. Persisted only (enforced:false): a per-provider
|
|
* concurrency limiter would live in the dispatch path (out of scope). */
|
|
export const ProviderPerformanceConfigSchema = z.object({
|
|
maxConcurrentRequests: z.number().int().positive().optional(),
|
|
});
|
|
export type ProviderPerformanceConfig = z.infer<
|
|
typeof ProviderPerformanceConfigSchema
|
|
>;
|
|
|
|
const ResetPeriodSchema = z.enum(["hourly", "daily", "weekly", "monthly"]);
|
|
export type ResetPeriod = z.infer<typeof ResetPeriodSchema>;
|
|
|
|
/** Governance tab (per-provider budgets/limits). Enforced: the provider
|
|
* manager consults a per-provider budget tracker (ProviderBudgetTracker,
|
|
* packages/governance/src/provider_budgets.ts) during provider selection. An
|
|
* account that has reached any configured limit is skipped in load-balancing
|
|
* and failover (the request falls through to the next eligible provider), and
|
|
* the request is rejected 429 when no eligible provider remains. Counters are
|
|
* fixed windows keyed to each field's reset period (hourly/daily/weekly/
|
|
* monthly; omit a period for a lifetime cap). Request counts accrue live on
|
|
* the dispatch path; token/cost counts accrue on the same billing path that
|
|
* meters virtual keys (recordTokens/recordCost — see the tracker docs and the
|
|
* boot-wiring note). Accounts with no governance group are unlimited. */
|
|
export const ProviderGovernanceConfigSchema = z.object({
|
|
budgetUsd: z.number().nonnegative().optional(),
|
|
budgetResetPeriod: ResetPeriodSchema.optional(),
|
|
maxTokens: z.number().int().nonnegative().optional(),
|
|
tokensResetPeriod: ResetPeriodSchema.optional(),
|
|
maxRequests: z.number().int().nonnegative().optional(),
|
|
requestsResetPeriod: ResetPeriodSchema.optional(),
|
|
});
|
|
export type ProviderGovernanceConfig = z.infer<
|
|
typeof ProviderGovernanceConfigSchema
|
|
>;
|
|
|
|
/** Beta-headers tab. Honored by the Anthropic adapter: entries set to
|
|
* "enabled" are joined into the `anthropic-beta` request header. Because the
|
|
* gateway sets no default betas, "disabled"/"default" are currently inert. */
|
|
export const ProviderBetaHeadersConfigSchema = z.object({
|
|
overrides: z.record(z.string(), z.enum(["default", "enabled", "disabled"]))
|
|
.optional(),
|
|
});
|
|
export type ProviderBetaHeadersConfig = z.infer<
|
|
typeof ProviderBetaHeadersConfigSchema
|
|
>;
|
|
|
|
/** Debugging tab. Persisted only (enforced:false): attaching raw req/resp to
|
|
* the response envelope or the request log requires dispatch-path plumbing
|
|
* (inference route + log store) that is out of scope. */
|
|
export const ProviderDebuggingConfigSchema = z.object({
|
|
sendBackRawRequest: z.boolean().optional(),
|
|
sendBackRawResponse: z.boolean().optional(),
|
|
storeRawReqResp: z.boolean().optional(),
|
|
});
|
|
export type ProviderDebuggingConfig = z.infer<
|
|
typeof ProviderDebuggingConfigSchema
|
|
>;
|
|
|
|
export const ProviderAccountConfigSchema = z.object({
|
|
id: z.string().min(1),
|
|
type: ProviderTypeSchema,
|
|
apiKey: z.string().optional(),
|
|
baseUrl: z.string().optional(),
|
|
/** Azure resource endpoint, e.g. https://my-resource.openai.azure.com */
|
|
endpoint: z.string().optional(),
|
|
/** Azure api-version query parameter */
|
|
apiVersion: z.string().optional(),
|
|
enabled: z.boolean().default(true),
|
|
/** Advertised model catalog; Azure entries are deployment names. */
|
|
models: z.array(z.string()).default([]),
|
|
/** Lower numbers are tried first when used as fallback targets. */
|
|
priority: z.number().default(0),
|
|
/** Optional weighted load-balancing within a priority tier: an account with
|
|
* a higher weight receives proportionally more traffic via deterministic
|
|
* weighted round-robin. Omitted or equal across a tier = plain round-robin
|
|
* (today's behavior). */
|
|
weight: z.number().positive().optional(),
|
|
/** Upstream retry policy; defaults live in the provider client. Superseded by
|
|
* network.{maxRetries,initialBackoffMs,maxBackoffMs} when those are set. */
|
|
retry: z.object({
|
|
maxRetries: z.number(),
|
|
initialDelayMs: z.number().optional(),
|
|
maxDelayMs: z.number().optional(),
|
|
}).optional(),
|
|
/** AWS Bedrock credentials/region. */
|
|
awsRegion: z.string().optional(),
|
|
awsAccessKeyId: z.string().optional(),
|
|
awsSecretAccessKey: z.string().optional(),
|
|
awsSessionToken: z.string().optional(),
|
|
/** Bedrock batch: IAM role ARN passed as roleArn on CreateModelInvocationJob. */
|
|
awsBatchRoleArn: z.string().optional(),
|
|
/** Bedrock batch: default s3://bucket/prefix URI for batch job output. */
|
|
awsBatchOutputS3Uri: z.string().optional(),
|
|
/** Bedrock files: default S3 bucket (may be s3://bucket/prefix). */
|
|
awsS3Bucket: z.string().optional(),
|
|
/** Bedrock files: default key prefix inside awsS3Bucket. */
|
|
awsS3Prefix: z.string().optional(),
|
|
/** Vertex AI: GCP project/location + service-account JSON (stringified). */
|
|
projectId: z.string().optional(),
|
|
location: z.string().optional(),
|
|
serviceAccountJson: z.string().optional(),
|
|
/** Optional egress proxy URL for this account's upstream calls. */
|
|
proxyUrl: z.string().optional(),
|
|
// --- 6-tab provider panel groups (all optional, additive) ---
|
|
network: ProviderNetworkConfigSchema.optional(),
|
|
proxy: ProviderProxyConfigSchema.optional(),
|
|
performance: ProviderPerformanceConfigSchema.optional(),
|
|
governance: ProviderGovernanceConfigSchema.optional(),
|
|
betaHeaders: ProviderBetaHeadersConfigSchema.optional(),
|
|
debugging: ProviderDebuggingConfigSchema.optional(),
|
|
});
|
|
export type ProviderAccountConfig = z.infer<typeof ProviderAccountConfigSchema>;
|
|
|
|
/** Redacted network group: certificate and header values are write-only. */
|
|
export const ProviderNetworkPublicSchema = ProviderNetworkConfigSchema.omit({
|
|
caCertPem: true,
|
|
extraHeaders: true,
|
|
}).extend({
|
|
extraHeaders: z.array(ProviderExtraHeaderPublicSchema).optional(),
|
|
});
|
|
export type ProviderNetworkPublic = z.infer<typeof ProviderNetworkPublicSchema>;
|
|
/** Redacted proxy group: password and bypass rules are write-only. */
|
|
export const ProviderProxyPublicSchema = ProviderProxyConfigSchema.omit({
|
|
proxyPassword: true,
|
|
noProxy: true,
|
|
}).extend({
|
|
noProxyCount: z.number().int().nonnegative(),
|
|
});
|
|
export type ProviderProxyPublic = z.infer<typeof ProviderProxyPublicSchema>;
|
|
|
|
/** Redacted variant safe for the browser: secrets replaced by presence flags.
|
|
* proxyUrl may embed credentials (http://user:pass@host), hence hasProxy;
|
|
* proxy.proxyPassword -> hasProxyPassword; network.caCertPem -> hasCaCert. */
|
|
export const ProviderAccountPublicSchema = ProviderAccountConfigSchema.omit({
|
|
apiKey: true,
|
|
awsSecretAccessKey: true,
|
|
awsSessionToken: true,
|
|
serviceAccountJson: true,
|
|
proxyUrl: true,
|
|
network: true,
|
|
proxy: true,
|
|
}).extend({
|
|
hasApiKey: z.boolean(),
|
|
hasCloudCredentials: z.boolean().optional(),
|
|
hasProxy: z.boolean().optional(),
|
|
hasProxyPassword: z.boolean().optional(),
|
|
hasCaCert: z.boolean().optional(),
|
|
network: ProviderNetworkPublicSchema.optional(),
|
|
proxy: ProviderProxyPublicSchema.optional(),
|
|
});
|
|
export type ProviderAccountPublic = z.infer<typeof ProviderAccountPublicSchema>;
|
|
|
|
/**
|
|
* Canonical redaction: full account config -> browser-safe public view. Used by
|
|
* both the admin route responses and the provider manager listing so they can
|
|
* never drift. Secrets (apiKey, cloud creds, proxyUrl, proxy.proxyPassword,
|
|
* network.caCertPem/header values, and proxy bypass rules) are dropped and
|
|
* replaced by presence metadata.
|
|
*/
|
|
export function redactProviderAccount(
|
|
config: ProviderAccountConfig,
|
|
): ProviderAccountPublic {
|
|
const {
|
|
apiKey,
|
|
awsSecretAccessKey,
|
|
awsSessionToken: _awsSessionToken,
|
|
serviceAccountJson,
|
|
proxyUrl,
|
|
network,
|
|
proxy,
|
|
...rest
|
|
} = config;
|
|
|
|
let redactedNetwork: ProviderNetworkPublic | undefined;
|
|
if (network) {
|
|
const { caCertPem: _caCertPem, extraHeaders, ...net } = network;
|
|
redactedNetwork = {
|
|
...net,
|
|
...(extraHeaders
|
|
? {
|
|
extraHeaders: extraHeaders.map(({ name, value }) => ({
|
|
name,
|
|
hasValue: value.length > 0,
|
|
})),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
let redactedProxy: ProviderProxyPublic | undefined;
|
|
if (proxy) {
|
|
const { proxyPassword: _proxyPassword, noProxy, ...px } = proxy;
|
|
redactedProxy = { ...px, noProxyCount: noProxy?.length ?? 0 };
|
|
}
|
|
|
|
return {
|
|
...rest,
|
|
...(redactedNetwork ? { network: redactedNetwork } : {}),
|
|
...(redactedProxy ? { proxy: redactedProxy } : {}),
|
|
hasApiKey: Boolean(apiKey),
|
|
hasCloudCredentials: Boolean(awsSecretAccessKey || serviceAccountJson),
|
|
hasProxy: Boolean(proxyUrl),
|
|
hasProxyPassword: Boolean(proxy?.proxyPassword),
|
|
hasCaCert: Boolean(network?.caCertPem),
|
|
};
|
|
}
|
|
|
|
export const GatewayConfigSchema = z.object({
|
|
defaultProvider: z.string().optional(),
|
|
providers: z.array(ProviderAccountConfigSchema).default([]),
|
|
}).strict();
|
|
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
|
|
|
export const ConfigExportSchema = z.object({
|
|
version: z.literal(1),
|
|
exportedAt: z.string(),
|
|
config: GatewayConfigSchema,
|
|
});
|
|
export type ConfigExport = z.infer<typeof ConfigExportSchema>;
|