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:
can1357
2026-02-28 17:54:39 +01:00
parent b9fafd29c2
commit a44f8f1f48
27 changed files with 1503 additions and 650 deletions
+26
View File
@@ -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
+8
View File
@@ -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"
}
}
}
+44 -45
View File
@@ -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))
+1 -1
View File
@@ -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 -517
View File
@@ -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.
*
-3
View File
@@ -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,
+16
View File
@@ -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;
}
+10
View File
@@ -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 },
};
+31
View File
@@ -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;
},
};
+18
View File
@@ -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);
}
+93
View File
@@ -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 };
}
+133
View File
@@ -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",
]);
+18
View File
@@ -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,
});
}
@@ -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");
}
+5
View File
@@ -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");
});
});
+61 -5
View File
@@ -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",
+245
View File
@@ -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,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", () => {
+1 -1
View File
@@ -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";