refactor(ai): restructured schema utilities into modular utils/schema package with unified strict mode enforcement
- Extracted schema utilities from typebox-helpers and google-shared into new modular utils/schema package with 17 exported functions. - Consolidated OpenAI strict mode schema enforcement across codex, completions, and responses providers using unified adaptSchemaForStrict() helper. - Refactored credential ranking from hardcoded Codex-specific logic to pluggable CredentialRankingStrategy pattern with provider implementations. - Migrated 500+ lines of Google schema sanitization and normalization logic from google-shared.ts to dedicated utils/schema modules with expanded functionality.
This commit is contained in:
@@ -1,6 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Exported schema utilities from new `./utils/schema` module, consolidating JSON Schema handling across providers
|
||||
- Added `CredentialRankingStrategy` interface for providers to implement usage-based credential selection
|
||||
- Added `claudeRankingStrategy` for Anthropic OAuth credentials to enable smart multi-account selection based on usage windows
|
||||
- Added `codexRankingStrategy` for OpenAI Codex OAuth credentials with priority boost for fresh 5-hour window starts
|
||||
- Added `adaptSchemaForStrict()` helper for unified OpenAI strict schema enforcement across providers
|
||||
- Added schema equality and merging utilities: `areJsonValuesEqual()`, `mergeCompatibleEnumSchemas()`, `mergePropertySchemas()`
|
||||
- Added Cloud Code Assist schema normalization: `copySchemaWithout()`, `stripResidualCombiners()`, `prepareSchemaForCloudCodeAssistClaude()`
|
||||
- Added `sanitizeSchemaForGoogle()` and `sanitizeSchemaForCloudCodeAssistClaude()` for provider-specific schema sanitization
|
||||
- Added `StringEnum()` helper for creating string enum schemas compatible with Google and other providers
|
||||
- Added `enforceStrictSchema()` and `sanitizeSchemaForStrictMode()` for OpenAI strict mode schema validation
|
||||
- Added package exports for `./utils/schema` and `./utils/schema/*` subpaths
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved schema utilities from `./utils/typebox-helpers` to new `./utils/schema` module with expanded functionality
|
||||
- Refactored OpenAI provider tool conversion to use unified `adaptSchemaForStrict()` helper across codex, completions, and responses
|
||||
- Updated `AuthStorage` to support generic credential ranking via `CredentialRankingStrategy` instead of Codex-only logic
|
||||
- Moved Google schema sanitization functions from `google-shared.ts` to `./utils/schema` module
|
||||
- Changed export path: `./utils/typebox-helpers` → `./utils/schema` in main index
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `./utils/typebox-helpers` module; use `./utils/schema` instead
|
||||
- Removed 500+ lines of schema normalization code from `google-shared.ts` (moved to `./utils/schema/normalize-cca.ts`)
|
||||
|
||||
## [13.3.8] - 2026-02-28
|
||||
### Fixed
|
||||
|
||||
@@ -117,6 +117,14 @@
|
||||
"./utils/oauth/*": {
|
||||
"types": "./src/utils/oauth/*.ts",
|
||||
"import": "./src/utils/oauth/*.ts"
|
||||
},
|
||||
"./utils/schema": {
|
||||
"types": "./src/utils/schema/index.ts",
|
||||
"import": "./src/utils/schema/index.ts"
|
||||
},
|
||||
"./utils/schema/*": {
|
||||
"types": "./src/utils/schema/*.ts",
|
||||
"import": "./src/utils/schema/*.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { googleGeminiCliUsageProvider } from "./providers/google-gemini-cli-usag
|
||||
import { getEnvApiKey } from "./stream";
|
||||
import type { Provider } from "./types";
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageCache,
|
||||
UsageCacheEntry,
|
||||
UsageCredential,
|
||||
@@ -23,11 +24,11 @@ import type {
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
} from "./usage";
|
||||
import { claudeUsageProvider } from "./usage/claude";
|
||||
import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
|
||||
import { githubCopilotUsageProvider } from "./usage/github-copilot";
|
||||
import { antigravityUsageProvider } from "./usage/google-antigravity";
|
||||
import { kimiUsageProvider } from "./usage/kimi";
|
||||
import { openaiCodexUsageProvider } from "./usage/openai-codex";
|
||||
import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
|
||||
import { zaiUsageProvider } from "./usage/zai";
|
||||
import { getOAuthApiKey, getOAuthProvider } from "./utils/oauth";
|
||||
// Re-export login functions so consumers of AuthStorage.login() have access
|
||||
@@ -114,6 +115,7 @@ export interface StoredAuthCredential {
|
||||
|
||||
export type AuthStorageOptions = {
|
||||
usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
||||
rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
||||
usageCache?: UsageCache;
|
||||
usageFetch?: typeof fetch;
|
||||
usageNow?: () => number;
|
||||
@@ -163,6 +165,15 @@ function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefi
|
||||
return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
|
||||
}
|
||||
|
||||
const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>([
|
||||
["openai-codex", codexRankingStrategy],
|
||||
["anthropic", claudeRankingStrategy],
|
||||
]);
|
||||
|
||||
function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
|
||||
return DEFAULT_RANKING_STRATEGIES.get(provider);
|
||||
}
|
||||
|
||||
function parseUsageCacheEntry(raw: string): UsageCacheEntry | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { value?: UsageReport | null; expiresAt?: unknown };
|
||||
@@ -228,6 +239,7 @@ export class AuthStorage {
|
||||
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
|
||||
#credentialBackoff: Map<string, Map<number, number>> = new Map();
|
||||
#usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
||||
#rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
||||
#usageCache?: UsageCache;
|
||||
#usageFetch: typeof fetch;
|
||||
#usageNow: () => number;
|
||||
@@ -240,6 +252,7 @@ export class AuthStorage {
|
||||
this.#store = store;
|
||||
this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
|
||||
this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
|
||||
this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
|
||||
this.#usageCache = options.usageCache ?? new AuthStorageUsageCache(this.#store);
|
||||
this.#usageFetch = options.usageFetch ?? fetch;
|
||||
this.#usageNow = options.usageNow ?? Date.now;
|
||||
@@ -1278,7 +1291,7 @@ export class AuthStorage {
|
||||
const now = this.#usageNow();
|
||||
let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
||||
|
||||
if (provider === "openai-codex" && sessionCredential.type === "oauth") {
|
||||
if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
|
||||
const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
|
||||
if (credential?.type === "oauth") {
|
||||
const report = await this.#getUsageReport(provider, credential, options);
|
||||
@@ -1345,33 +1358,13 @@ export class AuthStorage {
|
||||
return usedFraction / elapsedHours;
|
||||
}
|
||||
|
||||
#isFiveHourCodexTickerStart(limit: UsageLimit | undefined): boolean {
|
||||
if (!limit) return false;
|
||||
const windowId = limit.scope.windowId?.toLowerCase();
|
||||
const durationMs = limit.window?.durationMs;
|
||||
const fiveHourMs = 5 * 60 * 60 * 1000;
|
||||
const isFiveHourWindow =
|
||||
windowId === "5h" ||
|
||||
(typeof durationMs === "number" && Number.isFinite(durationMs) && Math.abs(durationMs - fiveHourMs) <= 60_000);
|
||||
if (!isFiveHourWindow) return false;
|
||||
const usedFraction = limit.amount.usedFraction;
|
||||
return typeof usedFraction === "number" && Number.isFinite(usedFraction) && usedFraction === 0;
|
||||
}
|
||||
|
||||
#findCodexWindowLimit(report: UsageReport, key: "primary" | "secondary"): UsageLimit | undefined {
|
||||
const direct = report.limits.find(limit => limit.id === `openai-codex:${key}`);
|
||||
if (direct) return direct;
|
||||
const byId = report.limits.find(limit => limit.id.toLowerCase().includes(key));
|
||||
if (byId) return byId;
|
||||
const windowId = key === "secondary" ? "7d" : "1h";
|
||||
return report.limits.find(limit => limit.scope.windowId?.toLowerCase() === windowId);
|
||||
}
|
||||
|
||||
async #rankCodexOAuthSelections(args: {
|
||||
async #rankOAuthSelections(args: {
|
||||
providerKey: string;
|
||||
provider: string;
|
||||
order: number[];
|
||||
credentials: Array<{ credential: OAuthCredential; index: number }>;
|
||||
options?: { baseUrl?: string };
|
||||
strategy: CredentialRankingStrategy;
|
||||
}): Promise<
|
||||
Array<{
|
||||
selection: { credential: OAuthCredential; index: number };
|
||||
@@ -1380,17 +1373,16 @@ export class AuthStorage {
|
||||
}>
|
||||
> {
|
||||
const nowMs = this.#usageNow();
|
||||
const weeklyWindowMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const primaryWindowMs = 60 * 60 * 1000;
|
||||
const { strategy } = args;
|
||||
const ranked: Array<{
|
||||
selection: { credential: OAuthCredential; index: number };
|
||||
usage: UsageReport | null;
|
||||
usageChecked: boolean;
|
||||
blocked: boolean;
|
||||
blockedUntil?: number;
|
||||
startsFiveHourTicker: boolean;
|
||||
weeklyUsed: number;
|
||||
weeklyDrainRate: number;
|
||||
hasPriorityBoost: boolean;
|
||||
secondaryUsed: number;
|
||||
secondaryDrainRate: number;
|
||||
primaryUsed: number;
|
||||
primaryDrainRate: number;
|
||||
orderPos: number;
|
||||
@@ -1402,7 +1394,7 @@ export class AuthStorage {
|
||||
if (!selection) return null;
|
||||
const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
|
||||
if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
|
||||
const usage = await this.#getUsageReport("openai-codex", selection.credential, args.options);
|
||||
const usage = await this.#getUsageReport(args.provider, selection.credential, args.options);
|
||||
return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
|
||||
}),
|
||||
);
|
||||
@@ -1419,20 +1411,25 @@ export class AuthStorage {
|
||||
this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
|
||||
blocked = true;
|
||||
}
|
||||
const secondary = usage ? this.#findCodexWindowLimit(usage, "secondary") : undefined;
|
||||
const primary = usage ? this.#findCodexWindowLimit(usage, "primary") : undefined;
|
||||
const weeklyTarget = secondary ?? primary;
|
||||
const windows = usage ? strategy.findWindowLimits(usage) : undefined;
|
||||
const primary = windows?.primary;
|
||||
const secondary = windows?.secondary;
|
||||
const secondaryTarget = secondary ?? primary;
|
||||
ranked.push({
|
||||
selection,
|
||||
usage,
|
||||
usageChecked,
|
||||
blocked,
|
||||
blockedUntil,
|
||||
startsFiveHourTicker: this.#isFiveHourCodexTickerStart(primary),
|
||||
weeklyUsed: this.#normalizeUsageFraction(weeklyTarget),
|
||||
weeklyDrainRate: this.#computeWindowDrainRate(weeklyTarget, nowMs, weeklyWindowMs),
|
||||
hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
|
||||
secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
|
||||
secondaryDrainRate: this.#computeWindowDrainRate(
|
||||
secondaryTarget,
|
||||
nowMs,
|
||||
strategy.windowDefaults.secondaryMs,
|
||||
),
|
||||
primaryUsed: this.#normalizeUsageFraction(primary),
|
||||
primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, primaryWindowMs),
|
||||
primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
|
||||
orderPos,
|
||||
});
|
||||
}
|
||||
@@ -1444,11 +1441,12 @@ export class AuthStorage {
|
||||
if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
|
||||
return left.orderPos - right.orderPos;
|
||||
}
|
||||
if (left.startsFiveHourTicker !== right.startsFiveHourTicker) {
|
||||
return left.startsFiveHourTicker ? -1 : 1;
|
||||
if (left.hasPriorityBoost !== right.hasPriorityBoost) {
|
||||
return left.hasPriorityBoost ? -1 : 1;
|
||||
}
|
||||
if (left.weeklyDrainRate !== right.weeklyDrainRate) return left.weeklyDrainRate - right.weeklyDrainRate;
|
||||
if (left.weeklyUsed !== right.weeklyUsed) return left.weeklyUsed - right.weeklyUsed;
|
||||
if (left.secondaryDrainRate !== right.secondaryDrainRate)
|
||||
return left.secondaryDrainRate - right.secondaryDrainRate;
|
||||
if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
|
||||
if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
|
||||
if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
|
||||
return left.orderPos - right.orderPos;
|
||||
@@ -1478,9 +1476,10 @@ export class AuthStorage {
|
||||
|
||||
const providerKey = this.#getProviderTypeKey(provider, "oauth");
|
||||
const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
|
||||
const checkUsage = provider === "openai-codex" && credentials.length > 1;
|
||||
const strategy = this.#rankingStrategyResolver?.(provider);
|
||||
const checkUsage = strategy !== undefined && credentials.length > 1;
|
||||
const candidates = checkUsage
|
||||
? await this.#rankCodexOAuthSelections({ providerKey, order, credentials, options })
|
||||
? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy })
|
||||
: order
|
||||
.map(idx => credentials[idx])
|
||||
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
|
||||
|
||||
@@ -35,5 +35,5 @@ export * from "./utils/event-stream";
|
||||
export * from "./utils/oauth";
|
||||
export * from "./utils/overflow";
|
||||
export * from "./utils/retry";
|
||||
export * from "./utils/typebox-helpers";
|
||||
export * from "./utils/schema";
|
||||
export * from "./utils/validation";
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
|
||||
*/
|
||||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
||||
import type { AnySchema } from "ajv";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import { prepareSchemaForCloudCodeAssistClaude, sanitizeSchemaForGoogle } from "../utils/schema";
|
||||
import { transformMessages } from "./transform-messages";
|
||||
|
||||
export { sanitizeSchemaForGoogle };
|
||||
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
|
||||
|
||||
/**
|
||||
@@ -258,521 +258,6 @@ ${params}
|
||||
return contents;
|
||||
}
|
||||
|
||||
const UNSUPPORTED_SCHEMA_FIELDS = new Set([
|
||||
"$schema",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"$dynamicRef",
|
||||
"$dynamicAnchor",
|
||||
"examples",
|
||||
"prefixItems",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"pattern",
|
||||
"format",
|
||||
]);
|
||||
|
||||
interface SanitizeSchemaOptions {
|
||||
insideProperties: boolean;
|
||||
normalizeTypeArrayToNullable: boolean;
|
||||
stripNullableKeyword: boolean;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function areJsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (!areJsonValuesEqual(left[i], right[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!isJsonObject(left) || !isJsonObject(right)) {
|
||||
return false;
|
||||
}
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of leftKeys) {
|
||||
if (!(key in right) || !areJsonValuesEqual(left[key], right[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeCompatibleEnumSchemas(existing: unknown, incoming: unknown): JsonObject | null {
|
||||
if (!isJsonObject(existing) || !isJsonObject(incoming)) {
|
||||
return null;
|
||||
}
|
||||
const existingEnum = Array.isArray(existing.enum) ? existing.enum : null;
|
||||
const incomingEnum = Array.isArray(incoming.enum) ? incoming.enum : null;
|
||||
if (!existingEnum || !incomingEnum) {
|
||||
return null;
|
||||
}
|
||||
if (!areJsonValuesEqual(existing.type, incoming.type)) {
|
||||
return null;
|
||||
}
|
||||
const existingKeys = Object.keys(existing).filter(key => key !== "enum");
|
||||
const incomingKeys = Object.keys(incoming).filter(key => key !== "enum");
|
||||
if (existingKeys.length !== incomingKeys.length) {
|
||||
return null;
|
||||
}
|
||||
for (const key of existingKeys) {
|
||||
if (!(key in incoming) || !areJsonValuesEqual(existing[key], incoming[key])) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mergedEnum = [...existingEnum];
|
||||
for (const enumValue of incomingEnum) {
|
||||
if (!mergedEnum.some(existingValue => Object.is(existingValue, enumValue))) {
|
||||
mergedEnum.push(enumValue);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
enum: mergedEnum,
|
||||
};
|
||||
}
|
||||
|
||||
function getAnyOfVariants(schema: unknown): unknown[] {
|
||||
if (isJsonObject(schema) && Array.isArray(schema.anyOf)) {
|
||||
return schema.anyOf;
|
||||
}
|
||||
return [schema];
|
||||
}
|
||||
|
||||
function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
|
||||
if (areJsonValuesEqual(existing, incoming)) {
|
||||
return existing;
|
||||
}
|
||||
const mergedEnumSchema = mergeCompatibleEnumSchemas(existing, incoming);
|
||||
if (mergedEnumSchema !== null) {
|
||||
return mergedEnumSchema;
|
||||
}
|
||||
|
||||
const mergedAnyOf = [...getAnyOfVariants(existing)];
|
||||
for (const variant of getAnyOfVariants(incoming)) {
|
||||
if (!mergedAnyOf.some(existingVariant => areJsonValuesEqual(existingVariant, variant))) {
|
||||
mergedAnyOf.push(variant);
|
||||
}
|
||||
}
|
||||
return mergedAnyOf.length === 1 ? mergedAnyOf[0] : { anyOf: mergedAnyOf };
|
||||
}
|
||||
|
||||
function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(entry => sanitizeSchemaImpl(entry, options));
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const combiner of ["anyOf", "oneOf"] as const) {
|
||||
if (Array.isArray(obj[combiner])) {
|
||||
const variants = obj[combiner] as Record<string, unknown>[];
|
||||
const allHaveConst = variants.every(v => v && typeof v === "object" && "const" in v);
|
||||
if (allHaveConst && variants.length > 0) {
|
||||
result.enum = variants.map(v => v.const);
|
||||
const firstType = variants[0]?.type;
|
||||
if (firstType) {
|
||||
result.type = firstType;
|
||||
}
|
||||
// Copy description and other top-level fields (not the combiner)
|
||||
for (const [key, entry] of Object.entries(obj)) {
|
||||
if (key !== combiner && !(key in result)) {
|
||||
result[key] = sanitizeSchemaImpl(entry, {
|
||||
insideProperties: false,
|
||||
normalizeTypeArrayToNullable: options.normalizeTypeArrayToNullable,
|
||||
stripNullableKeyword: options.stripNullableKeyword,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regular field processing
|
||||
let constValue: unknown;
|
||||
for (const [key, entry] of Object.entries(obj)) {
|
||||
// Only strip unsupported schema keywords when NOT inside "properties" object
|
||||
// Inside "properties", keys are property names (e.g., "pattern") not schema keywords
|
||||
if (!options.insideProperties && UNSUPPORTED_SCHEMA_FIELDS.has(key)) continue;
|
||||
if (options.stripNullableKeyword && key === "nullable") continue;
|
||||
if (key === "const") {
|
||||
constValue = entry;
|
||||
continue;
|
||||
}
|
||||
if (key === "additionalProperties" && entry === false) continue;
|
||||
// When key is "properties", child keys are property names, not schema keywords
|
||||
result[key] = sanitizeSchemaImpl(entry, {
|
||||
insideProperties: key === "properties",
|
||||
normalizeTypeArrayToNullable: options.normalizeTypeArrayToNullable,
|
||||
stripNullableKeyword: options.stripNullableKeyword,
|
||||
});
|
||||
}
|
||||
// Normalize array-valued "type" (e.g. ["string", "null"]) to a single type + nullable.
|
||||
// Google's Schema proto expects type to be a single enum string, not an array.
|
||||
if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) {
|
||||
const types = result.type as string[];
|
||||
const nonNull = types.filter(t => t !== "null");
|
||||
if (types.includes("null") && !options.stripNullableKeyword) {
|
||||
result.nullable = true;
|
||||
}
|
||||
result.type = nonNull[0] ?? types[0];
|
||||
}
|
||||
if (constValue !== undefined) {
|
||||
// Convert const to enum, merging with existing enum if present
|
||||
const existingEnum = Array.isArray(result.enum) ? result.enum : [];
|
||||
if (!existingEnum.some(item => Object.is(item, constValue))) {
|
||||
existingEnum.push(constValue);
|
||||
}
|
||||
result.enum = existingEnum;
|
||||
if (!result.type) {
|
||||
result.type =
|
||||
typeof constValue === "string"
|
||||
? "string"
|
||||
: typeof constValue === "number"
|
||||
? "number"
|
||||
: typeof constValue === "boolean"
|
||||
? "boolean"
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
export function sanitizeSchemaForGoogle(value: unknown): unknown {
|
||||
return sanitizeSchemaImpl(value, {
|
||||
insideProperties: false,
|
||||
normalizeTypeArrayToNullable: true,
|
||||
stripNullableKeyword: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize schema for Cloud Code Assist Claude. Uses normalizeTypeArrayToNullable + stripNullableKeyword
|
||||
* so `type: ["string", "null"]` becomes `type: "string"` with no nullable marker — intentional because
|
||||
* CCA/Claude doesn't support nullable.
|
||||
*/
|
||||
export function sanitizeSchemaForCloudCodeAssistClaude(value: unknown): unknown {
|
||||
return sanitizeSchemaImpl(value, {
|
||||
insideProperties: false,
|
||||
normalizeTypeArrayToNullable: true,
|
||||
stripNullableKeyword: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy all keys from a schema except the specified combiner key. */
|
||||
function copySchemaWithout(schema: JsonObject, combiner: string): JsonObject {
|
||||
const result: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(schema)) {
|
||||
if (key === combiner) continue;
|
||||
result[key] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude via Cloud Code Assist (`parameters` path) can reject schemas that keep
|
||||
* object variant combiners, so flatten object-only unions into one object shape.
|
||||
*/
|
||||
function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const variants: JsonObject[] = [];
|
||||
for (const entry of variantsRaw) {
|
||||
if (!isJsonObject(entry)) {
|
||||
return schema;
|
||||
}
|
||||
const variantType = entry.type;
|
||||
if (variantType !== undefined && variantType !== "object") {
|
||||
return schema;
|
||||
}
|
||||
if (entry.properties !== undefined && !isJsonObject(entry.properties)) {
|
||||
return schema;
|
||||
}
|
||||
variants.push(entry);
|
||||
}
|
||||
|
||||
const mergedProperties: JsonObject = {};
|
||||
const ownProperties = isJsonObject(schema.properties) ? schema.properties : {};
|
||||
for (const [name, propertySchema] of Object.entries(ownProperties)) {
|
||||
mergedProperties[name] = propertySchema;
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
const properties = isJsonObject(variant.properties) ? variant.properties : {};
|
||||
for (const [name, propertySchema] of Object.entries(properties)) {
|
||||
const existingSchema = mergedProperties[name];
|
||||
mergedProperties[name] =
|
||||
existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema);
|
||||
}
|
||||
}
|
||||
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
|
||||
nextSchema.type = "object";
|
||||
nextSchema.properties = mergedProperties;
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
array: new Set([
|
||||
"items",
|
||||
"prefixItems",
|
||||
"contains",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"unevaluatedItems",
|
||||
]),
|
||||
object: new Set([
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"unevaluatedProperties",
|
||||
]),
|
||||
string: new Set(["minLength", "maxLength", "pattern", "format", "contentEncoding", "contentMediaType"]),
|
||||
number: new Set(["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]),
|
||||
integer: new Set(["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]),
|
||||
boolean: new Set(),
|
||||
null: new Set(),
|
||||
};
|
||||
|
||||
const CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS = new Set([
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"examples",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"$comment",
|
||||
]);
|
||||
/**
|
||||
* Collapse anyOf/oneOf with distinct typed variants into a single-type schema.
|
||||
* Picks the first non-null type as a scalar. This is lossy for multi-type unions
|
||||
* (e.g., string|number|null narrows to string), but CCA requires a scalar type field
|
||||
* and an uncollapsed anyOf would be rejected by the CCA API at runtime.
|
||||
*/
|
||||
function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const seenTypes = new Set<string>();
|
||||
const variantTypes: string[] = [];
|
||||
const mergedVariantFields: JsonObject = {};
|
||||
for (const entry of variantsRaw) {
|
||||
if (!isJsonObject(entry) || typeof entry.type !== "string") {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const variantType = entry.type;
|
||||
if (seenTypes.has(variantType)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const allowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[variantType];
|
||||
if (!allowedKeys) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
for (const [key, variantValue] of Object.entries(entry)) {
|
||||
if (key === "type") continue;
|
||||
if (!allowedKeys.has(key) && !CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS.has(key)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const existingValue = mergedVariantFields[key];
|
||||
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
|
||||
return schema;
|
||||
}
|
||||
mergedVariantFields[key] = variantValue;
|
||||
}
|
||||
|
||||
seenTypes.add(variantType);
|
||||
variantTypes.push(variantType);
|
||||
}
|
||||
|
||||
if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
|
||||
const nonNullTypes = variantTypes.filter(t => t !== "null");
|
||||
// Lossy: when multiple non-null types exist we pick the first. CCA requires
|
||||
// a scalar type and keeping the anyOf would cause an API rejection at runtime.
|
||||
nextSchema.type = nonNullTypes[0] ?? variantTypes[0];
|
||||
for (const [key, value] of Object.entries(mergedVariantFields)) {
|
||||
const existingValue = nextSchema[key];
|
||||
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
|
||||
return schema;
|
||||
}
|
||||
if (existingValue === undefined) {
|
||||
nextSchema[key] = value;
|
||||
}
|
||||
}
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse anyOf/oneOf where all variants share the same primitive type.
|
||||
* E.g. anyOf: [{type: "string", desc: "A"}, {type: "string", desc: "B"}] → {type: "string", desc: "A"}
|
||||
* Claude via CCA rejects any remaining anyOf/oneOf, so pick first variant.
|
||||
* Note: constraints from non-first variants are silently dropped.
|
||||
*/
|
||||
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
|
||||
let commonType: string | undefined;
|
||||
let firstEntry: JsonObject | undefined;
|
||||
for (const entry of variantsRaw) {
|
||||
if (!isJsonObject(entry) || typeof entry.type !== "string") return schema;
|
||||
if (commonType === undefined) {
|
||||
commonType = entry.type;
|
||||
firstEntry = entry;
|
||||
} else if (entry.type !== commonType) return schema;
|
||||
}
|
||||
if (!firstEntry) return schema;
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
for (const [key, value] of Object.entries(firstEntry)) {
|
||||
if (!(key in nextSchema)) nextSchema[key] = value;
|
||||
}
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively strip any remaining anyOf/oneOf that collapseSameTypeCombinerVariants can handle.
|
||||
* This is needed because mergeObjectCombinerVariants can create new anyOf in merged
|
||||
* properties AFTER the recursive normalization pass has already processed children.
|
||||
*/
|
||||
function stripResidualCombiners(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stripResidualCombiners);
|
||||
if (!isJsonObject(value)) return value;
|
||||
const result: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = stripResidualCombiners(entry);
|
||||
}
|
||||
for (const combiner of ["anyOf", "oneOf"] as const) {
|
||||
const sametype = collapseSameTypeCombinerVariants(result, combiner);
|
||||
if (sametype !== result) return sametype;
|
||||
const mixed = collapseMixedTypeCombinerVariants(result, combiner);
|
||||
if (mixed !== result) return mixed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeSchemaForCloudCodeAssistClaude(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(entry => normalizeSchemaForCloudCodeAssistClaude(entry));
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
normalized[key] = normalizeSchemaForCloudCodeAssistClaude(entry);
|
||||
}
|
||||
|
||||
const mergedAnyOf = mergeObjectCombinerVariants(normalized, "anyOf");
|
||||
const collapsedAnyOf = collapseMixedTypeCombinerVariants(mergedAnyOf, "anyOf");
|
||||
const sameTypeAnyOf = collapseSameTypeCombinerVariants(collapsedAnyOf, "anyOf");
|
||||
const mergedOneOf = mergeObjectCombinerVariants(sameTypeAnyOf, "oneOf");
|
||||
const collapsedOneOf = collapseMixedTypeCombinerVariants(mergedOneOf, "oneOf");
|
||||
return collapseSameTypeCombinerVariants(collapsedOneOf, "oneOf");
|
||||
}
|
||||
|
||||
let cloudCodeAssistSchemaValidator: Ajv2020 | null = null;
|
||||
function getCloudCodeAssistSchemaValidator(): Ajv2020 {
|
||||
if (cloudCodeAssistSchemaValidator) {
|
||||
return cloudCodeAssistSchemaValidator;
|
||||
}
|
||||
|
||||
cloudCodeAssistSchemaValidator = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
validateSchema: true,
|
||||
});
|
||||
return cloudCodeAssistSchemaValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep validation synchronous in this request path.
|
||||
*/
|
||||
function isValidCloudCodeAssistClaudeSchema(schema: unknown): boolean {
|
||||
try {
|
||||
const result = getCloudCodeAssistSchemaValidator().validateSchema(schema as AnySchema);
|
||||
return typeof result === "boolean" ? result : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Prepare schema for Claude on Cloud Code Assist:
|
||||
* sanitize -> normalize union objects -> validate -> fallback.
|
||||
*
|
||||
* Fallback is per-tool and fail-open to avoid rejecting the entire request when
|
||||
* one tool schema is invalid.
|
||||
*/
|
||||
export function prepareSchemaForCloudCodeAssistClaude(value: unknown): unknown {
|
||||
const sanitized = sanitizeSchemaForCloudCodeAssistClaude(value);
|
||||
const pass1 = normalizeSchemaForCloudCodeAssistClaude(sanitized);
|
||||
// Second pass: strip anyOf/oneOf created by mergeObjectCombinerVariants during pass1
|
||||
const normalized = stripResidualCombiners(pass1);
|
||||
if (isValidCloudCodeAssistClaudeSchema(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tools to Gemini function declarations format.
|
||||
*
|
||||
|
||||
@@ -28,11 +28,8 @@ import {
|
||||
mapStopReason,
|
||||
mapToolChoice,
|
||||
retainThoughtSignature,
|
||||
sanitizeSchemaForGoogle,
|
||||
} from "./google-shared";
|
||||
|
||||
export { sanitizeSchemaForGoogle };
|
||||
|
||||
export interface GoogleOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any";
|
||||
thinking?: {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import { NO_STRICT, tryEnforceStrictSchema } from "../utils/typebox-helpers";
|
||||
import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
|
||||
import {
|
||||
CODEX_BASE_URL,
|
||||
JWT_CLAIM_PATH,
|
||||
@@ -1709,11 +1709,9 @@ function convertTools(tools: Tool[]): Array<{
|
||||
strict?: boolean;
|
||||
}> {
|
||||
return tools.map(tool => {
|
||||
const strict = !NO_STRICT && tool.strict;
|
||||
const strict = !!(!NO_STRICT && tool.strict);
|
||||
const baseParameters = tool.parameters as unknown as Record<string, unknown>;
|
||||
const strictResult = strict ? tryEnforceStrictSchema(baseParameters) : { schema: baseParameters, strict: false };
|
||||
const parameters = strictResult.schema;
|
||||
const effectiveStrict = strict && strictResult.strict;
|
||||
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
|
||||
@@ -32,8 +32,8 @@ import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-ins
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
|
||||
import { mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
|
||||
import { NO_STRICT, tryEnforceStrictSchema } from "../utils/typebox-helpers";
|
||||
import {
|
||||
buildCopilotDynamicHeaders,
|
||||
getCopilotInitiatorOverride,
|
||||
@@ -983,9 +983,7 @@ function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat.
|
||||
return tools.map(tool => {
|
||||
const strict = !NO_STRICT && compat.supportsStrictMode !== false && tool.strict !== false;
|
||||
const baseParameters = tool.parameters as unknown as Record<string, unknown>;
|
||||
const strictResult = strict ? tryEnforceStrictSchema(baseParameters) : { schema: baseParameters, strict: false };
|
||||
const parameters = strictResult.schema;
|
||||
const effectiveStrict = strict && strictResult.strict;
|
||||
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
|
||||
@@ -33,8 +33,8 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
|
||||
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
|
||||
import { NO_STRICT, tryEnforceStrictSchema } from "../utils/typebox-helpers";
|
||||
import {
|
||||
buildCopilotDynamicHeaders,
|
||||
getCopilotInitiatorOverride,
|
||||
@@ -700,9 +700,7 @@ function convertTools(tools: Tool[], strictMode: boolean): OpenAITool[] {
|
||||
return tools.map(tool => {
|
||||
const strict = !NO_STRICT && strictMode && tool.strict !== false;
|
||||
const baseParameters = tool.parameters as unknown as Record<string, unknown>;
|
||||
const strictResult = strict ? tryEnforceStrictSchema(baseParameters) : { schema: baseParameters, strict: false };
|
||||
const parameters = strictResult.schema;
|
||||
const effectiveStrict = strict && strictResult.strict;
|
||||
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
|
||||
@@ -130,3 +130,19 @@ export interface UsageProvider {
|
||||
fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null>;
|
||||
supports?(params: UsageFetchParams): boolean;
|
||||
}
|
||||
|
||||
/** Strategy for usage-based credential ranking. Providers implement this to opt into smart credential selection. */
|
||||
export interface CredentialRankingStrategy {
|
||||
/** Extract the primary (short) and secondary (long) window limits from a usage report. */
|
||||
findWindowLimits(report: UsageReport): {
|
||||
primary?: UsageLimit;
|
||||
secondary?: UsageLimit;
|
||||
};
|
||||
/** Fallback window durations (ms) when limits don't specify durationMs. */
|
||||
windowDefaults: {
|
||||
primaryMs: number;
|
||||
secondaryMs: number;
|
||||
};
|
||||
/** Optional: priority boost for specific credential states (e.g., fresh 5h ticker start). */
|
||||
hasPriorityBoost?(primary: UsageLimit | undefined): boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
@@ -400,3 +401,12 @@ export const claudeUsageProvider: UsageProvider = {
|
||||
fetchUsage: fetchClaudeUsage,
|
||||
supports: params => params.provider === "anthropic" && params.credential.type === "oauth",
|
||||
};
|
||||
|
||||
export const claudeRankingStrategy: CredentialRankingStrategy = {
|
||||
findWindowLimits(report) {
|
||||
const primary = report.limits.find(l => l.id === "anthropic:5h");
|
||||
const secondary = report.limits.find(l => l.id === "anthropic:7d");
|
||||
return { primary, secondary };
|
||||
},
|
||||
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { CODEX_BASE_URL } from "../providers/openai-codex/constants";
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageAmount,
|
||||
UsageCache,
|
||||
UsageFetchContext,
|
||||
@@ -411,3 +412,33 @@ export const openaiCodexUsageProvider: UsageProvider = {
|
||||
return report;
|
||||
},
|
||||
};
|
||||
|
||||
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
export const codexRankingStrategy: CredentialRankingStrategy = {
|
||||
findWindowLimits(report) {
|
||||
const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => {
|
||||
const direct = report.limits.find(l => l.id === `openai-codex:${key}`);
|
||||
if (direct) return direct;
|
||||
const byId = report.limits.find(l => l.id.toLowerCase().includes(key));
|
||||
if (byId) return byId;
|
||||
const windowId = key === "secondary" ? "7d" : "1h";
|
||||
return report.limits.find(l => l.scope.windowId?.toLowerCase() === windowId);
|
||||
};
|
||||
return { primary: findLimit("primary"), secondary: findLimit("secondary") };
|
||||
},
|
||||
windowDefaults: { primaryMs: 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
|
||||
hasPriorityBoost(primary) {
|
||||
if (!primary) return false;
|
||||
const windowId = primary.scope.windowId?.toLowerCase();
|
||||
const durationMs = primary.window?.durationMs;
|
||||
const isFiveHourWindow =
|
||||
windowId === "5h" ||
|
||||
(typeof durationMs === "number" &&
|
||||
Number.isFinite(durationMs) &&
|
||||
Math.abs(durationMs - FIVE_HOUR_MS) <= 60_000);
|
||||
if (!isFiveHourWindow) return false;
|
||||
const usedFraction = primary.amount.usedFraction;
|
||||
return typeof usedFraction === "number" && Number.isFinite(usedFraction) && usedFraction === 0;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { tryEnforceStrictSchema } from "./strict-mode";
|
||||
|
||||
/**
|
||||
* Consolidated helper for OpenAI-style strict schema enforcement.
|
||||
*
|
||||
* Each provider computes its own `strict` boolean (logic differs), then calls
|
||||
* this to handle the tryEnforceStrictSchema dance uniformly:
|
||||
* - If `strict` is false, passes the schema through unchanged.
|
||||
* - If `strict` is true, attempts to enforce strict mode; falls back to
|
||||
* non-strict if the schema isn't representable.
|
||||
*/
|
||||
export function adaptSchemaForStrict(
|
||||
schema: Record<string, unknown>,
|
||||
strict: boolean,
|
||||
): { schema: Record<string, unknown>; strict: boolean } {
|
||||
if (!strict) return { schema, strict: false };
|
||||
return tryEnforceStrictSchema(schema);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { JsonObject } from "./types";
|
||||
import { isJsonObject } from "./types";
|
||||
|
||||
export function areJsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (!areJsonValuesEqual(left[i], right[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!isJsonObject(left) || !isJsonObject(right)) {
|
||||
return false;
|
||||
}
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of leftKeys) {
|
||||
if (!(key in right) || !areJsonValuesEqual(left[key], right[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mergeCompatibleEnumSchemas(existing: unknown, incoming: unknown): JsonObject | null {
|
||||
if (!isJsonObject(existing) || !isJsonObject(incoming)) {
|
||||
return null;
|
||||
}
|
||||
const existingEnum = Array.isArray(existing.enum) ? existing.enum : null;
|
||||
const incomingEnum = Array.isArray(incoming.enum) ? incoming.enum : null;
|
||||
if (!existingEnum || !incomingEnum) {
|
||||
return null;
|
||||
}
|
||||
if (!areJsonValuesEqual(existing.type, incoming.type)) {
|
||||
return null;
|
||||
}
|
||||
const existingKeys = Object.keys(existing).filter(key => key !== "enum");
|
||||
const incomingKeys = Object.keys(incoming).filter(key => key !== "enum");
|
||||
if (existingKeys.length !== incomingKeys.length) {
|
||||
return null;
|
||||
}
|
||||
for (const key of existingKeys) {
|
||||
if (!(key in incoming) || !areJsonValuesEqual(existing[key], incoming[key])) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mergedEnum = [...existingEnum];
|
||||
for (const enumValue of incomingEnum) {
|
||||
if (!mergedEnum.some(existingValue => Object.is(existingValue, enumValue))) {
|
||||
mergedEnum.push(enumValue);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
enum: mergedEnum,
|
||||
};
|
||||
}
|
||||
|
||||
function getAnyOfVariants(schema: unknown): unknown[] {
|
||||
if (isJsonObject(schema) && Array.isArray(schema.anyOf)) {
|
||||
return schema.anyOf;
|
||||
}
|
||||
return [schema];
|
||||
}
|
||||
|
||||
export function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
|
||||
if (areJsonValuesEqual(existing, incoming)) {
|
||||
return existing;
|
||||
}
|
||||
const mergedEnumSchema = mergeCompatibleEnumSchemas(existing, incoming);
|
||||
if (mergedEnumSchema !== null) {
|
||||
return mergedEnumSchema;
|
||||
}
|
||||
|
||||
const mergedAnyOf = [...getAnyOfVariants(existing)];
|
||||
for (const variant of getAnyOfVariants(incoming)) {
|
||||
if (!mergedAnyOf.some(existingVariant => areJsonValuesEqual(existingVariant, variant))) {
|
||||
mergedAnyOf.push(variant);
|
||||
}
|
||||
}
|
||||
return mergedAnyOf.length === 1 ? mergedAnyOf[0] : { anyOf: mergedAnyOf };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Field classification sets for JSON Schema sanitization across providers.
|
||||
*
|
||||
* Each set serves a different provider need. They overlap intentionally —
|
||||
* co-locating them makes the overlap visible and maintainable.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google Generative AI unsupported schema fields.
|
||||
* Stripped during sanitizeSchemaForGoogle / sanitizeSchemaForCloudCodeAssistClaude.
|
||||
*/
|
||||
export const UNSUPPORTED_SCHEMA_FIELDS = new Set([
|
||||
"$schema",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"$dynamicRef",
|
||||
"$dynamicAnchor",
|
||||
"examples",
|
||||
"prefixItems",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"pattern",
|
||||
"format",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Non-structural schema keys stripped during OpenAI strict mode sanitization.
|
||||
* These are decorative/validation-only keywords that don't affect the structural
|
||||
* shape OpenAI's strict mode enforces.
|
||||
*/
|
||||
export const NON_STRUCTURAL_SCHEMA_KEYS = new Set([
|
||||
"format",
|
||||
"pattern",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"multipleOf",
|
||||
"$schema",
|
||||
"examples",
|
||||
"default",
|
||||
"title",
|
||||
"$comment",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"not",
|
||||
"prefixItems",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
"contains",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"contentEncoding",
|
||||
"contentMediaType",
|
||||
"contentSchema",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"$dynamicRef",
|
||||
"$dynamicAnchor",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cloud Code Assist type-specific allowed keys per JSON Schema type.
|
||||
* Used when collapsing mixed-type combiner variants for CCA Claude.
|
||||
*/
|
||||
export const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
array: new Set([
|
||||
"items",
|
||||
"prefixItems",
|
||||
"contains",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"unevaluatedItems",
|
||||
]),
|
||||
object: new Set([
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"unevaluatedProperties",
|
||||
]),
|
||||
string: new Set(["minLength", "maxLength", "pattern", "format", "contentEncoding", "contentMediaType"]),
|
||||
number: new Set(["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]),
|
||||
integer: new Set(["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]),
|
||||
boolean: new Set(),
|
||||
null: new Set(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Cloud Code Assist shared schema keys allowed on any type.
|
||||
* Used alongside CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS for CCA combiner collapsing.
|
||||
*/
|
||||
export const CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS = new Set([
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"examples",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"$comment",
|
||||
]);
|
||||
@@ -0,0 +1,18 @@
|
||||
export { adaptSchemaForStrict } from "./adapt";
|
||||
export { areJsonValuesEqual, mergeCompatibleEnumSchemas, mergePropertySchemas } from "./equality";
|
||||
export {
|
||||
CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS,
|
||||
CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS,
|
||||
NON_STRUCTURAL_SCHEMA_KEYS,
|
||||
UNSUPPORTED_SCHEMA_FIELDS,
|
||||
} from "./fields";
|
||||
export { copySchemaWithout, prepareSchemaForCloudCodeAssistClaude, stripResidualCombiners } from "./normalize-cca";
|
||||
export { sanitizeSchemaForCloudCodeAssistClaude, sanitizeSchemaForGoogle } from "./sanitize-google";
|
||||
export {
|
||||
enforceStrictSchema,
|
||||
NO_STRICT,
|
||||
StringEnum,
|
||||
sanitizeSchemaForStrictMode,
|
||||
tryEnforceStrictSchema,
|
||||
} from "./strict-mode";
|
||||
export { isJsonObject, type JsonObject } from "./types";
|
||||
@@ -0,0 +1,403 @@
|
||||
import type { AnySchema } from "ajv";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { areJsonValuesEqual, mergePropertySchemas } from "./equality";
|
||||
import { CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS } from "./fields";
|
||||
import { sanitizeSchemaForCloudCodeAssistClaude } from "./sanitize-google";
|
||||
import type { JsonObject } from "./types";
|
||||
import { isJsonObject } from "./types";
|
||||
|
||||
/** Copy all keys from a schema except the specified combiner key. */
|
||||
export function copySchemaWithout(schema: JsonObject, combiner: string): JsonObject {
|
||||
const result: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(schema)) {
|
||||
if (key === combiner) continue;
|
||||
result[key] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude via Cloud Code Assist (`parameters` path) can reject schemas that keep
|
||||
* object variant combiners, so flatten object-only unions into one object shape.
|
||||
*/
|
||||
function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const variants: JsonObject[] = [];
|
||||
for (const entry of variantsRaw) {
|
||||
if (!isJsonObject(entry)) {
|
||||
return schema;
|
||||
}
|
||||
const variantType = entry.type;
|
||||
const hasObjectShape =
|
||||
isJsonObject(entry.properties) || Array.isArray(entry.required) || Object.hasOwn(entry, "additionalProperties");
|
||||
if (variantType === undefined && !hasObjectShape) {
|
||||
return schema;
|
||||
}
|
||||
if (variantType !== undefined && variantType !== "object") {
|
||||
return schema;
|
||||
}
|
||||
if (entry.properties !== undefined && !isJsonObject(entry.properties)) {
|
||||
return schema;
|
||||
}
|
||||
if (entry.required !== undefined && !Array.isArray(entry.required)) {
|
||||
return schema;
|
||||
}
|
||||
variants.push(entry);
|
||||
}
|
||||
|
||||
const mergedProperties: JsonObject = {};
|
||||
const ownProperties = isJsonObject(schema.properties) ? schema.properties : {};
|
||||
for (const [name, propertySchema] of Object.entries(ownProperties)) {
|
||||
mergedProperties[name] = propertySchema;
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
const properties = isJsonObject(variant.properties) ? variant.properties : {};
|
||||
for (const [name, propertySchema] of Object.entries(properties)) {
|
||||
const existingSchema = mergedProperties[name];
|
||||
mergedProperties[name] =
|
||||
existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema);
|
||||
}
|
||||
}
|
||||
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
|
||||
nextSchema.type = "object";
|
||||
nextSchema.properties = mergedProperties;
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse anyOf/oneOf with distinct typed variants into a single-type schema.
|
||||
* Picks the first non-null type as a scalar. This is lossy for multi-type unions
|
||||
* (e.g., string|number|null narrows to string), but CCA requires a scalar type field
|
||||
* and an uncollapsed anyOf would be rejected by the CCA API at runtime.
|
||||
*/
|
||||
function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const seenTypes = new Set<string>();
|
||||
const variantTypes: string[] = [];
|
||||
const mergedVariantFields: JsonObject = {};
|
||||
for (const entry of variantsRaw) {
|
||||
if (!isJsonObject(entry) || typeof entry.type !== "string") {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const variantType = entry.type;
|
||||
if (seenTypes.has(variantType)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const allowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[variantType];
|
||||
if (!allowedKeys) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
for (const [key, variantValue] of Object.entries(entry)) {
|
||||
if (key === "type") continue;
|
||||
if (!allowedKeys.has(key) && !CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS.has(key)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const existingValue = mergedVariantFields[key];
|
||||
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
|
||||
return schema;
|
||||
}
|
||||
mergedVariantFields[key] = variantValue;
|
||||
}
|
||||
|
||||
seenTypes.add(variantType);
|
||||
variantTypes.push(variantType);
|
||||
}
|
||||
|
||||
if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
|
||||
const nonNullTypes = variantTypes.filter(t => t !== "null");
|
||||
// Lossy: when multiple non-null types exist we pick the first. CCA requires
|
||||
// a scalar type and keeping the anyOf would cause an API rejection at runtime.
|
||||
nextSchema.type = nonNullTypes[0] ?? variantTypes[0];
|
||||
for (const [key, value] of Object.entries(mergedVariantFields)) {
|
||||
const existingValue = nextSchema[key];
|
||||
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
|
||||
return schema;
|
||||
}
|
||||
if (existingValue === undefined) {
|
||||
nextSchema[key] = value;
|
||||
}
|
||||
}
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse anyOf/oneOf where all variants share the same primitive type.
|
||||
* E.g. anyOf: [{type: "string", desc: "A"}, {type: "string", desc: "B"}] -> {type: "string", desc: "A"}
|
||||
* Claude via CCA rejects any remaining anyOf/oneOf, so pick first variant.
|
||||
* Note: constraints from non-first variants are silently dropped.
|
||||
*/
|
||||
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
|
||||
let commonType: string | undefined;
|
||||
let firstEntry: JsonObject | undefined;
|
||||
for (const entry of variantsRaw) {
|
||||
if (!isJsonObject(entry) || typeof entry.type !== "string") return schema;
|
||||
if (commonType === undefined) {
|
||||
commonType = entry.type;
|
||||
firstEntry = entry;
|
||||
} else if (entry.type !== commonType) return schema;
|
||||
}
|
||||
if (!firstEntry) return schema;
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
for (const [key, value] of Object.entries(firstEntry)) {
|
||||
if (!(key in nextSchema)) nextSchema[key] = value;
|
||||
}
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively strip any remaining anyOf/oneOf that collapseSameTypeCombinerVariants can handle.
|
||||
* This is needed because mergeObjectCombinerVariants can create new anyOf in merged
|
||||
* properties AFTER the recursive normalization pass has already processed children.
|
||||
*/
|
||||
export function stripResidualCombiners(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stripResidualCombiners);
|
||||
if (!isJsonObject(value)) return value;
|
||||
const result: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = stripResidualCombiners(entry);
|
||||
}
|
||||
for (const combiner of ["anyOf", "oneOf"] as const) {
|
||||
const sametype = collapseSameTypeCombinerVariants(result, combiner);
|
||||
if (sametype !== result) return sametype;
|
||||
const mixed = collapseMixedTypeCombinerVariants(result, combiner);
|
||||
if (mixed !== result) return mixed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeSchemaForCloudCodeAssistClaude(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(entry => normalizeSchemaForCloudCodeAssistClaude(entry));
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
normalized[key] = normalizeSchemaForCloudCodeAssistClaude(entry);
|
||||
}
|
||||
|
||||
const mergedAnyOf = mergeObjectCombinerVariants(normalized, "anyOf");
|
||||
const collapsedAnyOf = collapseMixedTypeCombinerVariants(mergedAnyOf, "anyOf");
|
||||
const sameTypeAnyOf = collapseSameTypeCombinerVariants(collapsedAnyOf, "anyOf");
|
||||
const mergedOneOf = mergeObjectCombinerVariants(sameTypeAnyOf, "oneOf");
|
||||
const collapsedOneOf = collapseMixedTypeCombinerVariants(mergedOneOf, "oneOf");
|
||||
return collapseSameTypeCombinerVariants(collapsedOneOf, "oneOf");
|
||||
}
|
||||
|
||||
interface NullableExtractionResult {
|
||||
schema: unknown;
|
||||
nullable: boolean;
|
||||
}
|
||||
|
||||
function extractNullableUnionSchema(schema: unknown): NullableExtractionResult {
|
||||
if (!isJsonObject(schema)) {
|
||||
return { schema, nullable: false };
|
||||
}
|
||||
|
||||
if (schema.nullable === true) {
|
||||
const nextSchema = { ...schema };
|
||||
delete nextSchema.nullable;
|
||||
return { schema: nextSchema, nullable: true };
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
const typeVariants = schema.type.filter((entry): entry is string => typeof entry === "string");
|
||||
const nonNullTypes = typeVariants.filter(entry => entry !== "null");
|
||||
if (typeVariants.includes("null") && nonNullTypes.length === 1) {
|
||||
const nextSchema = { ...schema, type: nonNullTypes[0] };
|
||||
return { schema: nextSchema, nullable: true };
|
||||
}
|
||||
}
|
||||
|
||||
for (const combiner of ["anyOf", "oneOf"] as const) {
|
||||
const variantsRaw = schema[combiner];
|
||||
if (!Array.isArray(variantsRaw)) continue;
|
||||
|
||||
let hasNullVariant = false;
|
||||
const nonNullVariants: unknown[] = [];
|
||||
for (const variant of variantsRaw) {
|
||||
if (isJsonObject(variant) && variant.type === "null" && Object.keys(variant).length === 1) {
|
||||
hasNullVariant = true;
|
||||
continue;
|
||||
}
|
||||
nonNullVariants.push(variant);
|
||||
}
|
||||
|
||||
if (!hasNullVariant || nonNullVariants.length !== 1 || !isJsonObject(nonNullVariants[0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSchema = copySchemaWithout(schema, combiner);
|
||||
for (const [key, value] of Object.entries(nonNullVariants[0])) {
|
||||
const existingValue = nextSchema[key];
|
||||
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
|
||||
return { schema, nullable: false };
|
||||
}
|
||||
if (existingValue === undefined) {
|
||||
nextSchema[key] = value;
|
||||
}
|
||||
}
|
||||
return { schema: nextSchema, nullable: true };
|
||||
}
|
||||
|
||||
return { schema, nullable: false };
|
||||
}
|
||||
|
||||
interface NullableNormalizationResult {
|
||||
schema: unknown;
|
||||
nullable: boolean;
|
||||
}
|
||||
|
||||
function normalizeNullablePropertiesForCloudCodeAssist(
|
||||
value: unknown,
|
||||
isPropertySchema = false,
|
||||
): NullableNormalizationResult {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
schema: value.map(entry => normalizeNullablePropertiesForCloudCodeAssist(entry).schema),
|
||||
nullable: false,
|
||||
};
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
return { schema: value, nullable: false };
|
||||
}
|
||||
|
||||
const normalized: JsonObject = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(entry).schema;
|
||||
}
|
||||
|
||||
if (isJsonObject(normalized.properties)) {
|
||||
const required = new Set(
|
||||
Array.isArray(normalized.required)
|
||||
? normalized.required.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
);
|
||||
const nextProperties: JsonObject = {};
|
||||
for (const [name, propertySchema] of Object.entries(normalized.properties)) {
|
||||
const normalizedProperty = normalizeNullablePropertiesForCloudCodeAssist(propertySchema, true);
|
||||
nextProperties[name] = normalizedProperty.schema;
|
||||
if (normalizedProperty.nullable) {
|
||||
required.delete(name);
|
||||
}
|
||||
}
|
||||
normalized.properties = nextProperties;
|
||||
if (Array.isArray(normalized.required)) {
|
||||
normalized.required = Array.from(required);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPropertySchema) {
|
||||
return { schema: normalized, nullable: false };
|
||||
}
|
||||
|
||||
return extractNullableUnionSchema(normalized);
|
||||
}
|
||||
let cloudCodeAssistSchemaValidator: Ajv2020 | null = null;
|
||||
function getCloudCodeAssistSchemaValidator(): Ajv2020 {
|
||||
if (cloudCodeAssistSchemaValidator) {
|
||||
return cloudCodeAssistSchemaValidator;
|
||||
}
|
||||
|
||||
cloudCodeAssistSchemaValidator = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
validateSchema: true,
|
||||
});
|
||||
return cloudCodeAssistSchemaValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep validation synchronous in this request path.
|
||||
*/
|
||||
function isValidCloudCodeAssistClaudeSchema(schema: unknown): boolean {
|
||||
try {
|
||||
const result = getCloudCodeAssistSchemaValidator().validateSchema(schema as AnySchema);
|
||||
return typeof result === "boolean" ? result : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const CCA_FORBIDDEN_COMBINERS = new Set(["anyOf", "oneOf", "allOf"]);
|
||||
|
||||
function hasResidualCloudCodeAssistIncompatibilities(value: unknown, seen = new WeakSet<object>()): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(entry => hasResidualCloudCodeAssistIncompatibilities(entry, seen));
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return true;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value.type) || value.type === "null") {
|
||||
return true;
|
||||
}
|
||||
if (Object.hasOwn(value, "nullable")) {
|
||||
return true;
|
||||
}
|
||||
for (const combiner of CCA_FORBIDDEN_COMBINERS) {
|
||||
if (Array.isArray(value[combiner])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const entry of Object.values(value)) {
|
||||
if (hasResidualCloudCodeAssistIncompatibilities(entry, seen)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Prepare schema for Claude on Cloud Code Assist:
|
||||
* sanitize -> normalize union objects -> validate -> fallback.
|
||||
*
|
||||
* Fallback is per-tool and fail-open to avoid rejecting the entire request when
|
||||
* one tool schema is invalid.
|
||||
*/
|
||||
export function prepareSchemaForCloudCodeAssistClaude(value: unknown): unknown {
|
||||
const sanitized = sanitizeSchemaForCloudCodeAssistClaude(value);
|
||||
const pass1 = normalizeSchemaForCloudCodeAssistClaude(sanitized);
|
||||
// Second pass: strip anyOf/oneOf created by mergeObjectCombinerVariants during pass1
|
||||
const normalized = stripResidualCombiners(pass1);
|
||||
const nullableNormalized = normalizeNullablePropertiesForCloudCodeAssist(normalized).schema;
|
||||
if (hasResidualCloudCodeAssistIncompatibilities(nullableNormalized)) {
|
||||
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
|
||||
}
|
||||
if (isValidCloudCodeAssistClaudeSchema(nullableNormalized)) {
|
||||
return nullableNormalized;
|
||||
}
|
||||
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { UNSUPPORTED_SCHEMA_FIELDS } from "./fields";
|
||||
|
||||
interface SanitizeSchemaOptions {
|
||||
insideProperties: boolean;
|
||||
normalizeTypeArrayToNullable: boolean;
|
||||
stripNullableKeyword: boolean;
|
||||
}
|
||||
|
||||
function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(entry => sanitizeSchemaImpl(entry, options));
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const combiner of ["anyOf", "oneOf"] as const) {
|
||||
if (Array.isArray(obj[combiner])) {
|
||||
const variants = obj[combiner] as Record<string, unknown>[];
|
||||
const allHaveConst = variants.every(v => v && typeof v === "object" && "const" in v);
|
||||
if (allHaveConst && variants.length > 0) {
|
||||
result.enum = variants.map(v => v.const);
|
||||
const firstType = variants[0]?.type;
|
||||
if (firstType) {
|
||||
result.type = firstType;
|
||||
}
|
||||
// Copy description and other top-level fields (not the combiner)
|
||||
for (const [key, entry] of Object.entries(obj)) {
|
||||
if (key !== combiner && !(key in result)) {
|
||||
result[key] = sanitizeSchemaImpl(entry, {
|
||||
insideProperties: false,
|
||||
normalizeTypeArrayToNullable: options.normalizeTypeArrayToNullable,
|
||||
stripNullableKeyword: options.stripNullableKeyword,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regular field processing
|
||||
let constValue: unknown;
|
||||
for (const [key, entry] of Object.entries(obj)) {
|
||||
// Only strip unsupported schema keywords when NOT inside "properties" object
|
||||
// Inside "properties", keys are property names (e.g., "pattern") not schema keywords
|
||||
if (!options.insideProperties && UNSUPPORTED_SCHEMA_FIELDS.has(key)) continue;
|
||||
if (options.stripNullableKeyword && key === "nullable") continue;
|
||||
if (key === "const") {
|
||||
constValue = entry;
|
||||
continue;
|
||||
}
|
||||
if (key === "additionalProperties" && entry === false) continue;
|
||||
// When key is "properties", child keys are property names, not schema keywords
|
||||
result[key] = sanitizeSchemaImpl(entry, {
|
||||
insideProperties: key === "properties",
|
||||
normalizeTypeArrayToNullable: options.normalizeTypeArrayToNullable,
|
||||
stripNullableKeyword: options.stripNullableKeyword,
|
||||
});
|
||||
}
|
||||
// Normalize array-valued "type" (e.g. ["string", "null"]) to a single type + nullable.
|
||||
// Google's Schema proto expects type to be a single enum string, not an array.
|
||||
if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) {
|
||||
const types = result.type as string[];
|
||||
const nonNull = types.filter(t => t !== "null");
|
||||
if (types.includes("null") && !options.stripNullableKeyword) {
|
||||
result.nullable = true;
|
||||
}
|
||||
result.type = nonNull[0] ?? types[0];
|
||||
}
|
||||
if (constValue !== undefined) {
|
||||
// Convert const to enum, merging with existing enum if present
|
||||
const existingEnum = Array.isArray(result.enum) ? result.enum : [];
|
||||
if (!existingEnum.some(item => Object.is(item, constValue))) {
|
||||
existingEnum.push(constValue);
|
||||
}
|
||||
result.enum = existingEnum;
|
||||
if (!result.type) {
|
||||
result.type =
|
||||
typeof constValue === "string"
|
||||
? "string"
|
||||
: typeof constValue === "number"
|
||||
? "number"
|
||||
: typeof constValue === "boolean"
|
||||
? "boolean"
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeSchemaForGoogle(value: unknown): unknown {
|
||||
return sanitizeSchemaImpl(value, {
|
||||
insideProperties: false,
|
||||
normalizeTypeArrayToNullable: true,
|
||||
stripNullableKeyword: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize schema for Cloud Code Assist Claude. Uses normalizeTypeArrayToNullable + stripNullableKeyword
|
||||
* so `type: ["string", "null"]` becomes `type: "string"` with no nullable marker — intentional because
|
||||
* CCA/Claude doesn't support nullable.
|
||||
*/
|
||||
export function sanitizeSchemaForCloudCodeAssistClaude(value: unknown): unknown {
|
||||
return sanitizeSchemaImpl(value, {
|
||||
insideProperties: false,
|
||||
normalizeTypeArrayToNullable: true,
|
||||
stripNullableKeyword: true,
|
||||
});
|
||||
}
|
||||
+12
-58
@@ -1,4 +1,6 @@
|
||||
import { type TUnsafe, Type } from "@sinclair/typebox";
|
||||
import { NON_STRUCTURAL_SCHEMA_KEYS } from "./fields";
|
||||
import { isJsonObject } from "./types";
|
||||
|
||||
/**
|
||||
* Creates a string enum schema compatible with Google's API and other providers
|
||||
@@ -25,56 +27,8 @@ export function StringEnum<const T extends readonly string[]>(
|
||||
|
||||
export const NO_STRICT = Bun.env.PI_NO_STRICT === "1";
|
||||
|
||||
const NON_STRUCTURAL_SCHEMA_KEYS = new Set([
|
||||
"format",
|
||||
"pattern",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"multipleOf",
|
||||
"$schema",
|
||||
"examples",
|
||||
"default",
|
||||
"title",
|
||||
"$comment",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"not",
|
||||
"prefixItems",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
"contains",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"contentEncoding",
|
||||
"contentMediaType",
|
||||
"contentSchema",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"$dynamicRef",
|
||||
"$dynamicAnchor",
|
||||
]);
|
||||
|
||||
const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const;
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function sanitizeSchemaForStrictMode(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
const typeValue = schema.type;
|
||||
if (Array.isArray(typeValue)) {
|
||||
@@ -115,11 +69,11 @@ export function sanitizeSchemaForStrictMode(schema: Record<string, unknown>): Re
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "properties" && isObjectRecord(value)) {
|
||||
if (key === "properties" && isJsonObject(value)) {
|
||||
const properties = Object.fromEntries(
|
||||
Object.entries(value).map(([propertyName, propertySchema]) => [
|
||||
propertyName,
|
||||
isObjectRecord(propertySchema) ? sanitizeSchemaForStrictMode(propertySchema) : propertySchema,
|
||||
isJsonObject(propertySchema) ? sanitizeSchemaForStrictMode(propertySchema) : propertySchema,
|
||||
]),
|
||||
);
|
||||
sanitized.properties = properties;
|
||||
@@ -127,10 +81,10 @@ export function sanitizeSchemaForStrictMode(schema: Record<string, unknown>): Re
|
||||
}
|
||||
|
||||
if (key === "items") {
|
||||
if (isObjectRecord(value)) {
|
||||
if (isJsonObject(value)) {
|
||||
sanitized.items = sanitizeSchemaForStrictMode(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
sanitized.items = value.map(entry => (isObjectRecord(entry) ? sanitizeSchemaForStrictMode(entry) : entry));
|
||||
sanitized.items = value.map(entry => (isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry) : entry));
|
||||
} else {
|
||||
sanitized.items = value;
|
||||
}
|
||||
@@ -138,21 +92,21 @@ export function sanitizeSchemaForStrictMode(schema: Record<string, unknown>): Re
|
||||
}
|
||||
|
||||
if (COMBINATOR_KEYS.includes(key as (typeof COMBINATOR_KEYS)[number]) && Array.isArray(value)) {
|
||||
sanitized[key] = value.map(entry => (isObjectRecord(entry) ? sanitizeSchemaForStrictMode(entry) : entry));
|
||||
sanitized[key] = value.map(entry => (isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry) : entry));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((key === "$defs" || key === "definitions") && isObjectRecord(value)) {
|
||||
if ((key === "$defs" || key === "definitions") && isJsonObject(value)) {
|
||||
sanitized[key] = Object.fromEntries(
|
||||
Object.entries(value).map(([definitionName, definitionSchema]) => [
|
||||
definitionName,
|
||||
isObjectRecord(definitionSchema) ? sanitizeSchemaForStrictMode(definitionSchema) : definitionSchema,
|
||||
isJsonObject(definitionSchema) ? sanitizeSchemaForStrictMode(definitionSchema) : definitionSchema,
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "additionalProperties" && isObjectRecord(value)) {
|
||||
if (key === "additionalProperties" && isJsonObject(value)) {
|
||||
sanitized.additionalProperties = sanitizeSchemaForStrictMode(value);
|
||||
continue;
|
||||
}
|
||||
@@ -168,7 +122,7 @@ export function sanitizeSchemaForStrictMode(schema: Record<string, unknown>): Re
|
||||
sanitized.type = typeValue;
|
||||
}
|
||||
|
||||
if (sanitized.type === undefined && isObjectRecord(sanitized.properties)) {
|
||||
if (sanitized.type === undefined && isJsonObject(sanitized.properties)) {
|
||||
sanitized.type = "object";
|
||||
}
|
||||
|
||||
@@ -241,7 +195,7 @@ export function enforceStrictSchema(schema: Record<string, unknown>): Record<str
|
||||
result.type === undefined &&
|
||||
result.$ref === undefined &&
|
||||
!COMBINATOR_KEYS.some(key => Array.isArray(result[key])) &&
|
||||
!isObjectRecord(result.not)
|
||||
!isJsonObject(result.not)
|
||||
) {
|
||||
throw new Error("Schema node has no type, combinator, or $ref — cannot enforce strict mode");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export function isJsonObject(value: unknown): value is JsonObject {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -349,3 +349,257 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
expect(apiKey).toBe("api-acct-known");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Claude (Anthropic) ranking tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createClaudeLimit(args: {
|
||||
key: "5h" | "7d";
|
||||
durationMs: number;
|
||||
usedFraction: number;
|
||||
resetInMs: number;
|
||||
}): UsageLimit {
|
||||
const clamped = Math.min(Math.max(args.usedFraction, 0), 1);
|
||||
const now = Date.now();
|
||||
const used = clamped * 100;
|
||||
const label = args.key === "5h" ? "Claude 5 Hour" : "Claude 7 Day";
|
||||
return {
|
||||
id: `anthropic:${args.key}`,
|
||||
label,
|
||||
scope: {
|
||||
provider: "anthropic",
|
||||
windowId: args.key,
|
||||
shared: true,
|
||||
},
|
||||
window: {
|
||||
id: args.key,
|
||||
label,
|
||||
durationMs: args.durationMs,
|
||||
resetsAt: now + args.resetInMs,
|
||||
resetInMs: args.resetInMs,
|
||||
},
|
||||
amount: {
|
||||
unit: "percent",
|
||||
used,
|
||||
limit: 100,
|
||||
remaining: 100 - used,
|
||||
usedFraction: clamped,
|
||||
remainingFraction: Math.max(0, 1 - clamped),
|
||||
},
|
||||
status: clamped >= 1 ? "exhausted" : clamped >= 0.9 ? "warning" : "ok",
|
||||
};
|
||||
}
|
||||
|
||||
function createClaudeUsageReport(args: {
|
||||
accountId: string;
|
||||
primary: { usedFraction: number; resetInMs: number };
|
||||
secondary: { usedFraction: number; resetInMs: number };
|
||||
}): UsageReport {
|
||||
return {
|
||||
provider: "anthropic",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
createClaudeLimit({
|
||||
key: "5h",
|
||||
durationMs: FIVE_HOUR_MS,
|
||||
usedFraction: args.primary.usedFraction,
|
||||
resetInMs: args.primary.resetInMs,
|
||||
}),
|
||||
createClaudeLimit({
|
||||
key: "7d",
|
||||
durationMs: WEEK_MS,
|
||||
usedFraction: args.secondary.usedFraction,
|
||||
resetInMs: args.secondary.resetInMs,
|
||||
}),
|
||||
],
|
||||
metadata: { accountId: args.accountId },
|
||||
};
|
||||
}
|
||||
|
||||
describe("AuthStorage claude oauth ranking", () => {
|
||||
let tempDir = "";
|
||||
let store: AuthCredentialStore | null = null;
|
||||
let authStorage: AuthStorage | null = null;
|
||||
const usageByAccount = new Map<string, UsageReport>();
|
||||
|
||||
const usageProvider: UsageProvider = {
|
||||
id: "anthropic",
|
||||
async fetchUsage(params) {
|
||||
const accountId = params.credential.accountId;
|
||||
if (!accountId) return null;
|
||||
return usageByAccount.get(accountId) ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-claude-selection-"));
|
||||
store = await AuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
authStorage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "anthropic" ? usageProvider : undefined),
|
||||
});
|
||||
usageByAccount.clear();
|
||||
vi.spyOn(oauthUtils, "getOAuthApiKey").mockImplementation(async (_provider, credentials) => {
|
||||
const credential = credentials.anthropic as OAuthCredentials | undefined;
|
||||
if (!credential?.accountId) return null;
|
||||
return {
|
||||
apiKey: `api-${credential.accountId}`,
|
||||
newCredentials: credential,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
store?.close();
|
||||
store = null;
|
||||
authStorage = null;
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
test("prefers lower secondary drain rate account", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("anthropic", [
|
||||
{ type: "oauth", ...createCredential("acct-near", "near@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-far", "far@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-near",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-near",
|
||||
primary: { usedFraction: 0.4, resetInMs: 2 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.92, resetInMs: 15 * 60 * 1000 },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-far",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-far",
|
||||
primary: { usedFraction: 0.3, resetInMs: 4 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.55, resetInMs: 6 * 24 * HOUR_MS },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic", "session-claude-drain");
|
||||
expect(apiKey).toBe("api-acct-near");
|
||||
});
|
||||
|
||||
test("skips exhausted account and picks healthy", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("anthropic", [
|
||||
{ type: "oauth", ...createCredential("acct-exhausted", "exhausted@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-healthy", "healthy@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-exhausted",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-exhausted",
|
||||
primary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 },
|
||||
secondary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-healthy",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-healthy",
|
||||
primary: { usedFraction: 0.5, resetInMs: 3 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.4, resetInMs: 3 * 24 * HOUR_MS },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic", "session-claude-exhausted");
|
||||
expect(apiKey).toBe("api-acct-healthy");
|
||||
});
|
||||
|
||||
test("falls back to earliest-unblocking when all exhausted", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("anthropic", [
|
||||
{ type: "oauth", ...createCredential("acct-soon", "soon@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-later", "later@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-soon",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-soon",
|
||||
primary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 },
|
||||
secondary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-later",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-later",
|
||||
primary: { usedFraction: 1, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 1, resetInMs: 30 * 60 * 1000 },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic", "session-claude-all-exhausted");
|
||||
expect(apiKey).toBe("api-acct-soon");
|
||||
});
|
||||
|
||||
test("sorts 3 accounts by secondary drain rate", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("anthropic", [
|
||||
{ type: "oauth", ...createCredential("acct-fast", "fast@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-medium", "medium@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-slow", "slow@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-slow",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-slow",
|
||||
primary: { usedFraction: 0.2, resetInMs: 4 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.1, resetInMs: 6 * 24 * HOUR_MS },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-medium",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-medium",
|
||||
primary: { usedFraction: 0.2, resetInMs: 4 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.3, resetInMs: 5 * 24 * HOUR_MS },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-fast",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-fast",
|
||||
primary: { usedFraction: 0.2, resetInMs: 4 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.7, resetInMs: 3 * 24 * HOUR_MS },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic", "session-claude-three");
|
||||
expect(apiKey).toBe("api-acct-slow");
|
||||
});
|
||||
|
||||
test("single credential works without ranking", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("anthropic", [{ type: "oauth", ...createCredential("acct-solo", "solo@example.com") }]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-solo",
|
||||
createClaudeUsageReport({
|
||||
accountId: "acct-solo",
|
||||
primary: { usedFraction: 0.3, resetInMs: 3 * HOUR_MS },
|
||||
secondary: { usedFraction: 0.2, resetInMs: 5 * 24 * HOUR_MS },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic", "session-claude-single");
|
||||
expect(apiKey).toBe("api-acct-solo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
convertTools,
|
||||
sanitizeSchemaForCloudCodeAssistClaude,
|
||||
sanitizeSchemaForGoogle,
|
||||
} from "@oh-my-pi/pi-ai/providers/google-shared";
|
||||
import { convertTools } from "@oh-my-pi/pi-ai/providers/google-shared";
|
||||
import type { Model, Tool } from "@oh-my-pi/pi-ai/types";
|
||||
import { sanitizeSchemaForCloudCodeAssistClaude, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
function createModel(id: string): Model<"google-gemini-cli"> {
|
||||
@@ -201,6 +198,65 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
|
||||
});
|
||||
expect(JSON.stringify(declaration.parameters)).not.toContain('"anyOf"');
|
||||
});
|
||||
it("preserves nullable unions as optional properties instead of full fallback", () => {
|
||||
const parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
anyOf: [{ enum: ["A", "B"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
required: ["value"],
|
||||
} as unknown as TSchema;
|
||||
const tools: Tool[] = [{ name: "test_tool", description: "Test tool", parameters }];
|
||||
const claudeModel = createModel("claude-sonnet-4-5");
|
||||
const geminiModel = createModel("gemini-2.5-pro");
|
||||
|
||||
const claudeDeclaration = convertTools(tools, claudeModel)?.[0]?.functionDeclarations[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const geminiDeclaration = convertTools(tools, geminiModel)?.[0]?.functionDeclarations[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(claudeDeclaration.parameters).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { enum: ["A", "B"] },
|
||||
},
|
||||
required: [],
|
||||
});
|
||||
expect(JSON.stringify(claudeDeclaration.parameters)).not.toContain('"anyOf"');
|
||||
expect(
|
||||
(geminiDeclaration.parametersJsonSchema as { properties?: Record<string, unknown> })?.properties?.value,
|
||||
).toEqual(parameters.properties.value);
|
||||
});
|
||||
|
||||
it("falls back to minimal object schema when non-null unresolved unions remain for CCA Claude", () => {
|
||||
const parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
anyOf: [{ enum: ["A", "B"] }, { enum: ["C", "D"] }],
|
||||
},
|
||||
},
|
||||
required: ["value"],
|
||||
} as unknown as TSchema;
|
||||
const tools: Tool[] = [{ name: "test_tool", description: "Test tool", parameters }];
|
||||
const claudeModel = createModel("claude-sonnet-4-5");
|
||||
|
||||
const claudeDeclaration = convertTools(tools, claudeModel)?.[0]?.functionDeclarations[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(claudeDeclaration.parameters).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
it("keeps google sanitizer behavior for non-claude schema path", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
adaptSchemaForStrict,
|
||||
areJsonValuesEqual,
|
||||
copySchemaWithout,
|
||||
isJsonObject,
|
||||
mergeCompatibleEnumSchemas,
|
||||
mergePropertySchemas,
|
||||
stripResidualCombiners,
|
||||
} from "@oh-my-pi/pi-ai/utils/schema";
|
||||
|
||||
describe("isJsonObject", () => {
|
||||
it("returns true for plain objects", () => {
|
||||
expect(isJsonObject({})).toBe(true);
|
||||
expect(isJsonObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for arrays", () => {
|
||||
expect(isJsonObject([])).toBe(false);
|
||||
expect(isJsonObject([1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for primitives and null", () => {
|
||||
expect(isJsonObject(null)).toBe(false);
|
||||
expect(isJsonObject(undefined)).toBe(false);
|
||||
expect(isJsonObject(0)).toBe(false);
|
||||
expect(isJsonObject("")).toBe(false);
|
||||
expect(isJsonObject(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("areJsonValuesEqual", () => {
|
||||
it("returns true for identical primitives", () => {
|
||||
expect(areJsonValuesEqual(1, 1)).toBe(true);
|
||||
expect(areJsonValuesEqual("a", "a")).toBe(true);
|
||||
expect(areJsonValuesEqual(null, null)).toBe(true);
|
||||
expect(areJsonValuesEqual(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different primitives", () => {
|
||||
expect(areJsonValuesEqual(1, 2)).toBe(false);
|
||||
expect(areJsonValuesEqual("a", "b")).toBe(false);
|
||||
expect(areJsonValuesEqual(null, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares nested objects deeply", () => {
|
||||
expect(areJsonValuesEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true);
|
||||
expect(areJsonValuesEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false);
|
||||
});
|
||||
|
||||
it("compares arrays element-wise", () => {
|
||||
expect(areJsonValuesEqual([1, 2, 3], [1, 2, 3])).toBe(true);
|
||||
expect(areJsonValuesEqual([1, 2], [1, 2, 3])).toBe(false);
|
||||
expect(areJsonValuesEqual([1, 2], [2, 1])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles mixed types correctly", () => {
|
||||
expect(areJsonValuesEqual([], {})).toBe(false);
|
||||
expect(areJsonValuesEqual({}, [])).toBe(false);
|
||||
expect(areJsonValuesEqual(1, "1")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty structures", () => {
|
||||
expect(areJsonValuesEqual({}, {})).toBe(true);
|
||||
expect(areJsonValuesEqual([], [])).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes NaN via Object.is", () => {
|
||||
expect(areJsonValuesEqual(Number.NaN, Number.NaN)).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes +0 and -0 via Object.is", () => {
|
||||
expect(areJsonValuesEqual(0, -0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCompatibleEnumSchemas", () => {
|
||||
it("merges two enum schemas with the same type", () => {
|
||||
const a = { type: "string", enum: ["x", "y"] };
|
||||
const b = { type: "string", enum: ["y", "z"] };
|
||||
const result = mergeCompatibleEnumSchemas(a, b);
|
||||
expect(result).toEqual({ type: "string", enum: ["x", "y", "z"] });
|
||||
});
|
||||
|
||||
it("returns null when types differ", () => {
|
||||
const a = { type: "string", enum: ["x"] };
|
||||
const b = { type: "number", enum: [1] };
|
||||
expect(mergeCompatibleEnumSchemas(a, b)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when non-enum keys differ", () => {
|
||||
const a = { type: "string", enum: ["x"], description: "A" };
|
||||
const b = { type: "string", enum: ["y"], description: "B" };
|
||||
expect(mergeCompatibleEnumSchemas(a, b)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when one input lacks enum", () => {
|
||||
const a = { type: "string", enum: ["x"] };
|
||||
const b = { type: "string" };
|
||||
expect(mergeCompatibleEnumSchemas(a, b)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object inputs", () => {
|
||||
expect(mergeCompatibleEnumSchemas(null, null)).toBeNull();
|
||||
expect(mergeCompatibleEnumSchemas("x", "y")).toBeNull();
|
||||
});
|
||||
|
||||
it("deduplicates enum values", () => {
|
||||
const a = { type: "number", enum: [1, 2] };
|
||||
const b = { type: "number", enum: [2, 3] };
|
||||
const result = mergeCompatibleEnumSchemas(a, b);
|
||||
expect(result).toEqual({ type: "number", enum: [1, 2, 3] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePropertySchemas", () => {
|
||||
it("returns existing when schemas are equal", () => {
|
||||
const schema = { type: "string" };
|
||||
expect(mergePropertySchemas(schema, { type: "string" })).toEqual(schema);
|
||||
});
|
||||
|
||||
it("merges compatible enum schemas", () => {
|
||||
const a = { type: "string", enum: ["x"] };
|
||||
const b = { type: "string", enum: ["y"] };
|
||||
expect(mergePropertySchemas(a, b)).toEqual({ type: "string", enum: ["x", "y"] });
|
||||
});
|
||||
|
||||
it("creates anyOf for incompatible schemas", () => {
|
||||
const a = { type: "string" };
|
||||
const b = { type: "number" };
|
||||
expect(mergePropertySchemas(a, b)).toEqual({ anyOf: [a, b] });
|
||||
});
|
||||
|
||||
it("appends to existing anyOf without duplicates", () => {
|
||||
const a = { anyOf: [{ type: "string" }, { type: "number" }] };
|
||||
const b = { type: "boolean" };
|
||||
const result = mergePropertySchemas(a, b) as { anyOf: unknown[] };
|
||||
expect(result.anyOf).toHaveLength(3);
|
||||
expect(result.anyOf).toContainEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
it("does not duplicate when merging with existing anyOf variant", () => {
|
||||
const a = { anyOf: [{ type: "string" }, { type: "number" }] };
|
||||
const b = { type: "string" };
|
||||
const result = mergePropertySchemas(a, b) as { anyOf: unknown[] };
|
||||
expect(result.anyOf).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copySchemaWithout", () => {
|
||||
it("copies all keys except the specified one", () => {
|
||||
const schema = { type: "object", anyOf: [1, 2], description: "test" };
|
||||
const result = copySchemaWithout(schema, "anyOf");
|
||||
expect(result).toEqual({ type: "object", description: "test" });
|
||||
});
|
||||
|
||||
it("returns a copy when key is not present", () => {
|
||||
const schema = { type: "string" };
|
||||
const result = copySchemaWithout(schema, "anyOf");
|
||||
expect(result).toEqual({ type: "string" });
|
||||
expect(result).not.toBe(schema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripResidualCombiners", () => {
|
||||
it("collapses same-type anyOf into single schema", () => {
|
||||
const input = {
|
||||
anyOf: [
|
||||
{ type: "string", description: "A" },
|
||||
{ type: "string", description: "B" },
|
||||
],
|
||||
};
|
||||
const result = stripResidualCombiners(input) as Record<string, unknown>;
|
||||
expect(result.type).toBe("string");
|
||||
expect(result.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collapses nested residual combiners", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
field: {
|
||||
anyOf: [
|
||||
{ type: "string", description: "A" },
|
||||
{ type: "string", description: "B" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = stripResidualCombiners(input) as Record<string, unknown>;
|
||||
const props = result.properties as Record<string, Record<string, unknown>>;
|
||||
expect(props.field.type).toBe("string");
|
||||
expect(props.field.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves non-collapsible combiners", () => {
|
||||
const input = {
|
||||
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
|
||||
};
|
||||
// This has 3 variants of different types but all are distinct non-null.
|
||||
// collapseMixedTypeCombinerVariants should collapse this to first non-null type.
|
||||
const result = stripResidualCombiners(input) as Record<string, unknown>;
|
||||
expect(result.type).toBe("string");
|
||||
});
|
||||
|
||||
it("passes through primitives and arrays unchanged", () => {
|
||||
expect(stripResidualCombiners("hello")).toBe("hello");
|
||||
expect(stripResidualCombiners(42)).toBe(42);
|
||||
expect(stripResidualCombiners(null)).toBe(null);
|
||||
expect(stripResidualCombiners([1, 2])).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptSchemaForStrict", () => {
|
||||
it("passes through when strict is false", () => {
|
||||
const schema = { type: "object", properties: { x: { type: "string" } } };
|
||||
const result = adaptSchemaForStrict(schema, false);
|
||||
expect(result.strict).toBe(false);
|
||||
expect(result.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it("enforces strict mode for valid schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { x: { type: "string" } },
|
||||
required: ["x"],
|
||||
};
|
||||
const result = adaptSchemaForStrict(schema, true);
|
||||
expect(result.strict).toBe(true);
|
||||
expect(result.schema.additionalProperties).toBe(false);
|
||||
});
|
||||
|
||||
it("degrades gracefully for non-representable schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: { items: {}, type: "array" },
|
||||
},
|
||||
required: ["items"],
|
||||
};
|
||||
const result = adaptSchemaForStrict(schema, true);
|
||||
expect(result.strict).toBe(false);
|
||||
expect(result.schema).toBe(schema);
|
||||
});
|
||||
});
|
||||
+1
-5
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
enforceStrictSchema,
|
||||
sanitizeSchemaForStrictMode,
|
||||
tryEnforceStrictSchema,
|
||||
} from "@oh-my-pi/pi-ai/utils/typebox-helpers";
|
||||
import { enforceStrictSchema, sanitizeSchemaForStrictMode, tryEnforceStrictSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
describe("sanitizeSchemaForStrictMode", () => {
|
||||
@@ -5,7 +5,7 @@ import * as path from "node:path";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai/models";
|
||||
import { complete, stream } from "@oh-my-pi/pi-ai/stream";
|
||||
import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
|
||||
import { StringEnum } from "@oh-my-pi/pi-ai/utils/typebox-helpers";
|
||||
import { StringEnum } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { e2eApiKey, resolveApiKey } from "./oauth";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Subagents must call this tool to finish and return structured JSON output.
|
||||
*/
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import { sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/typebox-helpers";
|
||||
import { sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { Tool, ToolCall } from "@oh-my-pi/pi-ai/types";
|
||||
import { enforceStrictSchema } from "@oh-my-pi/pi-ai/utils/typebox-helpers";
|
||||
import { enforceStrictSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
Reference in New Issue
Block a user