From a44f8f1f4810772c7bebe1a0355faa894f0fbe12 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 28 Feb 2026 17:54:39 +0100 Subject: [PATCH] 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. --- packages/ai/CHANGELOG.md | 26 + packages/ai/package.json | 8 + packages/ai/src/auth-storage.ts | 89 ++- packages/ai/src/index.ts | 2 +- packages/ai/src/providers/google-shared.ts | 519 +----------------- packages/ai/src/providers/google.ts | 3 - .../src/providers/openai-codex-responses.ts | 8 +- .../ai/src/providers/openai-completions.ts | 6 +- packages/ai/src/providers/openai-responses.ts | 6 +- packages/ai/src/usage.ts | 16 + packages/ai/src/usage/claude.ts | 10 + packages/ai/src/usage/openai-codex.ts | 31 ++ packages/ai/src/utils/schema/adapt.ts | 18 + packages/ai/src/utils/schema/equality.ts | 93 ++++ packages/ai/src/utils/schema/fields.ts | 133 +++++ packages/ai/src/utils/schema/index.ts | 18 + packages/ai/src/utils/schema/normalize-cca.ts | 403 ++++++++++++++ .../ai/src/utils/schema/sanitize-google.ts | 112 ++++ .../strict-mode.ts} | 70 +-- packages/ai/src/utils/schema/types.ts | 5 + .../test/auth-storage-codex-selection.test.ts | 254 +++++++++ packages/ai/test/google-tool-schema.test.ts | 66 ++- packages/ai/test/schema-helpers.test.ts | 245 +++++++++ ...ers.test.ts => schema-strict-mode.test.ts} | 6 +- packages/ai/test/stream.test.ts | 2 +- .../coding-agent/src/tools/submit-result.ts | 2 +- .../test/tools/submit-result.test.ts | 2 +- 27 files changed, 1503 insertions(+), 650 deletions(-) create mode 100644 packages/ai/src/utils/schema/adapt.ts create mode 100644 packages/ai/src/utils/schema/equality.ts create mode 100644 packages/ai/src/utils/schema/fields.ts create mode 100644 packages/ai/src/utils/schema/index.ts create mode 100644 packages/ai/src/utils/schema/normalize-cca.ts create mode 100644 packages/ai/src/utils/schema/sanitize-google.ts rename packages/ai/src/utils/{typebox-helpers.ts => schema/strict-mode.ts} (80%) create mode 100644 packages/ai/src/utils/schema/types.ts create mode 100644 packages/ai/test/schema-helpers.test.ts rename packages/ai/test/{typebox-helpers.test.ts => schema-strict-mode.test.ts} (98%) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 08e8cbb58..9606c9d29 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/package.json b/packages/ai/package.json index 9310c4a84..f20c8c870 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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" } } } diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 68d65c73b..f255715c9 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.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([ + ["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> = 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)) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 3c4d1c52e..81a31b982 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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"; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 29077fc08..6b994923f 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -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; - -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; - const result: Record = {}; - for (const combiner of ["anyOf", "oneOf"] as const) { - if (Array.isArray(obj[combiner])) { - const variants = obj[combiner] as Record[]; - 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> = { - 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(); - 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. * diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 657cef612..6f6be73fe 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -28,11 +28,8 @@ import { mapStopReason, mapToolChoice, retainThoughtSignature, - sanitizeSchemaForGoogle, } from "./google-shared"; -export { sanitizeSchemaForGoogle }; - export interface GoogleOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; thinking?: { diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index e13aad453..72188a8f0 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -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; - 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, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 8b7d6e200..0ec5c9440 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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; - 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: { diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 18dbb6426..5719de3f0 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -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; - 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, diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index b0ed1fc5f..9a740a4e7 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -130,3 +130,19 @@ export interface UsageProvider { fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise; 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; +} diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index 80f86b8f6..cb416a228 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -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 }, +}; diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 2ce2119bd..d4277417c 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -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; + }, +}; diff --git a/packages/ai/src/utils/schema/adapt.ts b/packages/ai/src/utils/schema/adapt.ts new file mode 100644 index 000000000..9e64b0e69 --- /dev/null +++ b/packages/ai/src/utils/schema/adapt.ts @@ -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, + strict: boolean, +): { schema: Record; strict: boolean } { + if (!strict) return { schema, strict: false }; + return tryEnforceStrictSchema(schema); +} diff --git a/packages/ai/src/utils/schema/equality.ts b/packages/ai/src/utils/schema/equality.ts new file mode 100644 index 000000000..e2378c152 --- /dev/null +++ b/packages/ai/src/utils/schema/equality.ts @@ -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 }; +} diff --git a/packages/ai/src/utils/schema/fields.ts b/packages/ai/src/utils/schema/fields.ts new file mode 100644 index 000000000..a53d9d1e8 --- /dev/null +++ b/packages/ai/src/utils/schema/fields.ts @@ -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> = { + 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", +]); diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts new file mode 100644 index 000000000..5c6c1964e --- /dev/null +++ b/packages/ai/src/utils/schema/index.ts @@ -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"; diff --git a/packages/ai/src/utils/schema/normalize-cca.ts b/packages/ai/src/utils/schema/normalize-cca.ts new file mode 100644 index 000000000..346bac574 --- /dev/null +++ b/packages/ai/src/utils/schema/normalize-cca.ts @@ -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(); + 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()): 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; +} diff --git a/packages/ai/src/utils/schema/sanitize-google.ts b/packages/ai/src/utils/schema/sanitize-google.ts new file mode 100644 index 000000000..5d06f9fbe --- /dev/null +++ b/packages/ai/src/utils/schema/sanitize-google.ts @@ -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; + const result: Record = {}; + for (const combiner of ["anyOf", "oneOf"] as const) { + if (Array.isArray(obj[combiner])) { + const variants = obj[combiner] as Record[]; + 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, + }); +} diff --git a/packages/ai/src/utils/typebox-helpers.ts b/packages/ai/src/utils/schema/strict-mode.ts similarity index 80% rename from packages/ai/src/utils/typebox-helpers.ts rename to packages/ai/src/utils/schema/strict-mode.ts index 9396be12a..da3949cb8 100644 --- a/packages/ai/src/utils/typebox-helpers.ts +++ b/packages/ai/src/utils/schema/strict-mode.ts @@ -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( 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 { - return value != null && typeof value === "object" && !Array.isArray(value); -} - export function sanitizeSchemaForStrictMode(schema: Record): Record { const typeValue = schema.type; if (Array.isArray(typeValue)) { @@ -115,11 +69,11 @@ export function sanitizeSchemaForStrictMode(schema: Record): 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): 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): 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): 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): Record 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"); } diff --git a/packages/ai/src/utils/schema/types.ts b/packages/ai/src/utils/schema/types.ts new file mode 100644 index 000000000..ec98ceaeb --- /dev/null +++ b/packages/ai/src/utils/schema/types.ts @@ -0,0 +1,5 @@ +export type JsonObject = Record; + +export function isJsonObject(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 7a3930f42..903a17ab4 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -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(); + + 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"); + }); +}); diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index bc526c79c..bd3bb7122 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -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 })?.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", diff --git a/packages/ai/test/schema-helpers.test.ts b/packages/ai/test/schema-helpers.test.ts new file mode 100644 index 000000000..bd0d2ea7d --- /dev/null +++ b/packages/ai/test/schema-helpers.test.ts @@ -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; + 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; + const props = result.properties as Record>; + 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; + 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); + }); +}); diff --git a/packages/ai/test/typebox-helpers.test.ts b/packages/ai/test/schema-strict-mode.test.ts similarity index 98% rename from packages/ai/test/typebox-helpers.test.ts rename to packages/ai/test/schema-strict-mode.test.ts index 6875511ea..286ee7c20 100644 --- a/packages/ai/test/typebox-helpers.test.ts +++ b/packages/ai/test/schema-strict-mode.test.ts @@ -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", () => { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index b9bc429f5..6a796e1f8 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -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"; diff --git a/packages/coding-agent/src/tools/submit-result.ts b/packages/coding-agent/src/tools/submit-result.ts index 19797d38d..cde31fe7f 100644 --- a/packages/coding-agent/src/tools/submit-result.ts +++ b/packages/coding-agent/src/tools/submit-result.ts @@ -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"; diff --git a/packages/coding-agent/test/tools/submit-result.test.ts b/packages/coding-agent/test/tools/submit-result.test.ts index b670129c3..8ebfee9da 100644 --- a/packages/coding-agent/test/tools/submit-result.test.ts +++ b/packages/coding-agent/test/tools/submit-result.test.ts @@ -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";