diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 42f6b11e9f5..c0d9b4570c6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -373,6 +373,20 @@ export namespace Config { export const Permission = z.enum(["ask", "allow", "deny"]) export type Permission = z.infer + /** + * Cache TTL options for provider caching + */ + export const CacheTTL = z + .enum(["5m", "1h", "auto"]) + .describe("Cache time-to-live: '5m' for 5 minutes, '1h' for 1 hour, 'auto' for provider default") + export type CacheTTL = z.infer + + /** + * Prompt section identifiers for ordering + */ + export const PromptSection = z.enum(["tools", "instructions", "environment", "system", "messages"]) + export type PromptSection = z.infer + export const Command = z.object({ template: z.string(), description: z.string().optional(), @@ -382,6 +396,36 @@ export namespace Config { }) export type Command = z.infer + /** + * Agent-specific cache configuration (can override provider defaults) + */ + export const AgentCacheConfig = z + .object({ + enabled: z.boolean().optional().describe("Enable or disable caching for this agent"), + ttl: CacheTTL.optional().describe("Cache time-to-live for this agent"), + minTokens: z.number().int().nonnegative().optional().describe("Minimum tokens required for content to be cached"), + maxBreakpoints: z.number().int().nonnegative().optional().describe("Maximum number of cache breakpoints"), + }) + .strict() + .meta({ + ref: "AgentCacheConfig", + }) + export type AgentCacheConfig = z.infer + + /** + * Agent-specific prompt ordering configuration (can override provider defaults) + */ + export const AgentPromptOrderConfig = z + .object({ + ordering: z.array(PromptSection).optional().describe("Order of prompt sections for this agent"), + cacheBreakpoints: z.array(PromptSection).optional().describe("Sections that should have cache breakpoints"), + }) + .strict() + .meta({ + ref: "AgentPromptOrderConfig", + }) + export type AgentPromptOrderConfig = z.infer + export const Agent = z .object({ model: z.string().optional(), @@ -403,6 +447,8 @@ export namespace Config { .positive() .optional() .describe("Maximum number of agentic iterations before forcing text-only response"), + cache: AgentCacheConfig.optional().describe("Agent-specific cache configuration (overrides provider defaults)"), + promptOrder: AgentPromptOrderConfig.optional().describe("Agent-specific prompt ordering configuration"), permission: z .object({ edit: Permission.optional(), @@ -575,11 +621,51 @@ export namespace Config { }) export type Layout = z.infer + /** + * User-configurable cache settings for a provider + */ + export const ProviderCacheConfig = z + .object({ + enabled: z.boolean().optional().describe("Enable or disable caching for this provider"), + ttl: CacheTTL.optional().describe("Cache time-to-live"), + minTokens: z.number().int().nonnegative().optional().describe("Minimum tokens required for content to be cached"), + maxBreakpoints: z + .number() + .int() + .nonnegative() + .optional() + .describe("Maximum number of cache breakpoints (for explicit caching providers)"), + }) + .strict() + .meta({ + ref: "ProviderCacheConfig", + }) + export type ProviderCacheConfig = z.infer + + /** + * User-configurable prompt ordering settings for a provider + */ + export const ProviderPromptOrderConfig = z + .object({ + ordering: z.array(PromptSection).optional().describe("Order of prompt sections for optimal caching"), + cacheBreakpoints: z + .array(PromptSection) + .optional() + .describe("Sections that should have cache breakpoints applied"), + }) + .strict() + .meta({ + ref: "ProviderPromptOrderConfig", + }) + export type ProviderPromptOrderConfig = z.infer + export const Provider = ModelsDev.Provider.partial() .extend({ whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), models: z.record(z.string(), ModelsDev.Model.partial()).optional(), + cache: ProviderCacheConfig.optional().describe("Provider-specific cache configuration"), + promptOrder: ProviderPromptOrderConfig.optional().describe("Provider-specific prompt ordering configuration"), options: z .object({ apiKey: z.string().optional(), diff --git a/packages/opencode/src/provider/config.ts b/packages/opencode/src/provider/config.ts new file mode 100644 index 00000000000..a9df08f72bf --- /dev/null +++ b/packages/opencode/src/provider/config.ts @@ -0,0 +1,953 @@ +import type { Provider } from "./provider" + +/** + * Provider Configuration System + * + * This namespace provides a comprehensive configuration system for provider-specific + * caching and prompt ordering optimizations. It supports three caching paradigms: + * + * 1. Explicit Breakpoint (Anthropic, Bedrock, Google Vertex Anthropic) + * - Uses explicit cache markers (cacheControl, cachePoint) + * - Has a hierarchy of what to cache and max breakpoints + * + * 2. Automatic Prefix (OpenAI, Azure, GitHub Copilot, DeepSeek) + * - Caching is automatic based on prefix matching + * - No explicit markers needed + * + * 3. Implicit/Content-based (Google/Gemini 2.5+) + * - Provider handles caching automatically + * - Based on content hashing + * + * Configuration hierarchy (highest priority last): + * Provider Defaults → User Provider Config → User Agent Config + * + * NOTE: Many settings here (minTokens, TTL, cache type, etc.) are hardcoded because + * models.dev does not provide this caching metadata. These values are derived from: + * - Provider documentation (Anthropic, OpenAI, Google, etc.) + * - Empirical testing + * - Community knowledge + * + * If models.dev adds caching metadata in the future, we should migrate to using it. + */ +export namespace ProviderConfig { + // ============================================ + // TYPE DEFINITIONS + // ============================================ + + /** + * Cache type determines how caching is applied + */ + export type CacheType = "explicit-breakpoint" | "automatic-prefix" | "implicit" | "passthrough" | "none" + + /** + * TTL values supported by providers + */ + export type CacheTTL = "5m" | "1h" | "auto" + + /** + * Prompt section identifiers for ordering + */ + export type PromptSection = "tools" | "instructions" | "environment" | "system" | "messages" + + /** + * Model family patterns for minTokens resolution + */ + export interface MinTokensByModel { + [pattern: string]: number + default: number + } + + /** + * Cache configuration for a provider + */ + export interface CacheConfig { + /** Whether caching is enabled */ + enabled: boolean + /** Type of caching mechanism */ + type: CacheType + /** Property name used for cache control (e.g., 'cacheControl', 'cachePoint', 'cache_control') */ + property: string | null + /** Order of sections to apply cache breakpoints */ + hierarchy: PromptSection[] + /** Time-to-live for cached content */ + ttl: CacheTTL + /** Minimum tokens required for caching (can be number or model-specific map) */ + minTokens: number | MinTokensByModel + /** Maximum number of cache breakpoints allowed */ + maxBreakpoints: number + } + + /** + * How system prompts should be passed to the provider + * - "role": Use system role in messages array (OpenAI, Mistral) + * - "parameter": Use top-level system parameter (Anthropic, Bedrock) + * - "systemInstruction": Use systemInstruction field (Google/Gemini) + * + * Note: The AI SDK handles most of this automatically, but we use this + * to inform our caching and ordering strategies. + */ + export type SystemPromptMode = "role" | "parameter" | "systemInstruction" + + /** + * Prompt ordering configuration for a provider + */ + export interface PromptOrderConfig { + /** Order of prompt sections for optimal caching */ + ordering: PromptSection[] + /** Sections that should have cache breakpoints applied */ + cacheBreakpoints: PromptSection[] + /** Whether to combine all system content into a single message (most providers require this) */ + combineSystemMessages: boolean + /** How system prompts are passed to the provider */ + systemPromptMode: SystemPromptMode + /** Whether tools can be cached (explicit breakpoint providers only) */ + toolCaching: boolean + /** Whether provider requires alternating user/assistant messages */ + requiresAlternatingRoles: boolean + /** Whether to sort tools alphabetically for consistent prefix matching */ + sortTools: boolean + } + + /** + * Complete provider configuration + */ + export interface Config { + cache: CacheConfig + promptOrder: PromptOrderConfig + } + + /** + * User-configurable overrides (partial) + */ + export interface UserCacheConfig { + enabled?: boolean + ttl?: CacheTTL + minTokens?: number + maxBreakpoints?: number + } + + export interface UserPromptOrderConfig { + ordering?: PromptSection[] + cacheBreakpoints?: PromptSection[] + } + + export interface UserConfig { + cache?: UserCacheConfig + promptOrder?: UserPromptOrderConfig + } + + // ============================================ + // PROVIDER DEFAULTS + // ============================================ + + /** + * Default configurations for all supported providers + * + * IMPORTANT: These values are hardcoded because models.dev does not provide: + * - minTokens: Minimum tokens required for caching (varies by model) + * - ttl: Cache time-to-live (provider-specific) + * - cacheType: How caching works (explicit markers vs automatic) + * - maxBreakpoints: Maximum cache breakpoints allowed + * + * Sources for these values: + * - Anthropic: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching + * - OpenAI: https://platform.openai.com/docs/guides/prompt-caching + * - Google: https://ai.google.dev/gemini-api/docs/caching + * - AWS Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html + */ + export const defaults: Record = { + // ---------------------------------------- + // EXPLICIT BREAKPOINT PROVIDERS + // These providers require explicit cache_control markers on messages + // ---------------------------------------- + + anthropic: { + cache: { + enabled: true, + type: "explicit-breakpoint", + property: "cacheControl", + hierarchy: ["tools", "system", "messages"], + ttl: "5m", // Anthropic cache TTL is 5 minutes (not configurable via API) + // minTokens values from Anthropic docs - minimum cacheable content size + // https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#requirements + minTokens: { + // Claude 4.x family - higher minimum for larger models + "claude-opus-4": 4096, + "claude-opus-4-5": 4096, + "claude-opus-4.5": 4096, + "claude-sonnet-4": 2048, + "claude-sonnet-4-5": 2048, + "claude-sonnet-4.5": 2048, + "claude-haiku-4": 2048, + "claude-haiku-4-5": 2048, + "claude-haiku-4.5": 2048, + // Claude 3.x family + "claude-3-opus": 2048, + "claude-3-5-opus": 2048, + "claude-3-sonnet": 1024, + "claude-3-5-sonnet": 2048, + "claude-3-haiku": 1024, + "claude-3-5-haiku": 2048, + default: 1024, + }, + maxBreakpoints: 4, + }, + promptOrder: { + ordering: ["tools", "instructions", "environment", "system", "messages"], + cacheBreakpoints: ["tools", "system", "messages"], + combineSystemMessages: false, // Anthropic supports multiple system messages + systemPromptMode: "parameter", + toolCaching: true, + requiresAlternatingRoles: true, + sortTools: true, + }, + }, + + "amazon-bedrock": { + cache: { + enabled: true, + type: "explicit-breakpoint", + property: "cachePoint", // Bedrock uses "cachePoint" instead of "cacheControl" + // Bedrock has different hierarchy than direct Anthropic + hierarchy: ["system", "messages", "tools"], + ttl: "5m", // Bedrock cache TTL matches Anthropic (5 minutes) + // minTokens for Bedrock models - Nova has lower minimums than Claude + // https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html + minTokens: { + // Amazon Nova models - 1000 token minimum + "nova-micro": 1000, + "nova-lite": 1000, + "nova-pro": 1000, + "nova-premier": 1000, + // Claude 4.x family + "claude-opus-4": 4096, + "claude-opus-4-5": 4096, + "claude-opus-4.5": 4096, + "claude-sonnet-4": 2048, + "claude-sonnet-4-5": 2048, + "claude-sonnet-4.5": 2048, + "claude-haiku-4": 2048, + "claude-haiku-4-5": 2048, + "claude-haiku-4.5": 2048, + // Claude 3.x family + "claude-3-opus": 2048, + "claude-3-5-opus": 2048, + "claude-3-sonnet": 1024, + "claude-3-5-sonnet": 2048, + "claude-3-haiku": 1024, + "claude-3-5-haiku": 2048, + default: 1024, + }, + maxBreakpoints: 4, + }, + promptOrder: { + // Bedrock: system first, tools at end + ordering: ["system", "instructions", "environment", "messages", "tools"], + cacheBreakpoints: ["system", "messages", "tools"], + combineSystemMessages: false, // Bedrock supports multiple system messages for Claude + systemPromptMode: "parameter", + toolCaching: true, + requiresAlternatingRoles: true, + sortTools: true, + }, + }, + + "google-vertex-anthropic": { + cache: { + enabled: true, + type: "explicit-breakpoint", + property: "cacheControl", + hierarchy: ["tools", "system", "messages"], + ttl: "5m", + minTokens: { + // Claude 4.x family + "claude-opus-4": 4096, + "claude-opus-4-5": 4096, + "claude-opus-4.5": 4096, + "claude-sonnet-4": 2048, + "claude-sonnet-4-5": 2048, + "claude-sonnet-4.5": 2048, + "claude-haiku-4": 2048, + "claude-haiku-4-5": 2048, + "claude-haiku-4.5": 2048, + // Claude 3.x family + "claude-3-opus": 2048, + "claude-3-5-opus": 2048, + "claude-3-sonnet": 1024, + "claude-3-5-sonnet": 2048, + "claude-3-haiku": 1024, + "claude-3-5-haiku": 2048, + default: 1024, + }, + maxBreakpoints: 4, + }, + promptOrder: { + ordering: ["tools", "instructions", "environment", "system", "messages"], + cacheBreakpoints: ["tools", "system", "messages"], + combineSystemMessages: false, // Supports multiple system messages + systemPromptMode: "parameter", + toolCaching: true, + requiresAlternatingRoles: true, + sortTools: true, + }, + }, + + // ---------------------------------------- + // AUTOMATIC PREFIX PROVIDERS + // These providers automatically cache based on prefix matching. + // No explicit markers needed - just ensure consistent ordering. + // ---------------------------------------- + + openai: { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, // No explicit cache markers needed + hierarchy: [], + ttl: "auto", // OpenAI manages TTL automatically (5-60 min based on usage) + minTokens: 1024, // OpenAI requires 1024 token minimum for caching + maxBreakpoints: 0, // Not applicable for prefix caching + }, + promptOrder: { + // Static content first for prefix caching + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // OpenAI prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, // OpenAI allows consecutive same-role + sortTools: true, // Sort for prefix consistency + }, + }, + + azure: { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Azure OpenAI prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + "azure-cognitive-services": { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + "github-copilot": { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // OpenAI-compatible, prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + "github-copilot-enterprise": { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // OpenAI-compatible, prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + opencode: { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + deepseek: { + cache: { + enabled: true, + type: "automatic-prefix", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, // DeepSeek has no minimum + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["instructions", "tools", "environment", "system", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // DeepSeek prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + // ---------------------------------------- + // IMPLICIT CACHING PROVIDERS + // These providers handle caching automatically based on content hashing. + // No special ordering or markers needed. + // ---------------------------------------- + + google: { + cache: { + enabled: true, + type: "implicit", // Gemini 2.5+ uses implicit context caching + property: null, + hierarchy: [], + ttl: "auto", // Google manages TTL (1 hour default, configurable via API) + // Gemini minTokens - content must exceed this for caching + // https://ai.google.dev/gemini-api/docs/caching + minTokens: { + "gemini-2.5-pro": 4096, + "gemini-2.5-flash": 2048, + "gemini-2.0-flash": 2048, + "gemini-2.0-pro": 4096, + "gemini-3": 2048, + default: 2048, + }, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Gemini uses systemInstruction, prefers single + systemPromptMode: "systemInstruction", + toolCaching: false, + requiresAlternatingRoles: true, // Gemini requires alternating user/model + sortTools: false, + }, + }, + + "google-vertex": { + cache: { + enabled: true, + type: "implicit", // Same as google - uses implicit context caching + property: null, + hierarchy: [], + ttl: "auto", + // Same minTokens as google + minTokens: { + "gemini-2.5-pro": 4096, + "gemini-2.5-flash": 2048, + "gemini-2.0-flash": 2048, + "gemini-2.0-pro": 4096, + "gemini-3": 2048, + default: 2048, + }, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Gemini uses systemInstruction, prefers single + systemPromptMode: "systemInstruction", + toolCaching: false, + requiresAlternatingRoles: true, // Gemini requires alternating user/model + sortTools: false, + }, + }, + + // ---------------------------------------- + // PASSTHROUGH PROVIDERS + // ---------------------------------------- + + openrouter: { + cache: { + enabled: true, + type: "passthrough", + property: "cache_control", + // Use Anthropic-style for passthrough since most users use Claude via OpenRouter + hierarchy: ["tools", "system", "messages"], + ttl: "5m", + minTokens: 1024, + maxBreakpoints: 4, + }, + promptOrder: { + // Optimized for Anthropic models (most common on OpenRouter) + ordering: ["tools", "instructions", "environment", "system", "messages"], + cacheBreakpoints: ["tools", "system"], + combineSystemMessages: false, // Depends on underlying model, allow multiple + systemPromptMode: "parameter", // For Claude models + toolCaching: true, + requiresAlternatingRoles: true, // Most models on OpenRouter require this + sortTools: true, + }, + }, + + vercel: { + cache: { + enabled: true, + type: "passthrough", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Default to single for safety + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + zenmux: { + cache: { + enabled: true, + type: "passthrough", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 1024, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Default to single for safety + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: true, + }, + }, + + // ---------------------------------------- + // NO CACHING PROVIDERS + // These providers do not support prompt caching (as of this writing). + // Check provider docs periodically as caching support may be added. + // ---------------------------------------- + + mistral: { + cache: { + enabled: false, + type: "none", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Mistral prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: false, + }, + }, + + qwen: { + cache: { + enabled: false, + type: "none", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Qwen prefers single system message + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: false, + }, + }, + + cerebras: { + cache: { + enabled: false, + type: "none", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Default to single for safety + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: false, + }, + }, + + "sap-ai-core": { + cache: { + enabled: false, + type: "none", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Default to single for safety + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: false, + }, + }, + + baseten: { + cache: { + enabled: false, + type: "none", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Default to single for safety + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: false, + }, + }, + + // ---------------------------------------- + // DEFAULT FALLBACK + // ---------------------------------------- + + default: { + cache: { + enabled: false, + type: "none", + property: null, + hierarchy: [], + ttl: "auto", + minTokens: 0, + maxBreakpoints: 0, + }, + promptOrder: { + ordering: ["system", "instructions", "environment", "tools", "messages"], + cacheBreakpoints: [], + combineSystemMessages: true, // Default to single for maximum compatibility + systemPromptMode: "role", + toolCaching: false, + requiresAlternatingRoles: false, + sortTools: false, + }, + }, + } + + // ============================================ + // CONFIG RESOLUTION + // ============================================ + + /** + * Resolve minTokens for a specific model + */ + export function resolveMinTokens(minTokens: number | MinTokensByModel, model?: Provider.Model): number { + if (typeof minTokens === "number") return minTokens + if (!model) return minTokens.default + + const modelID = model.id.toLowerCase() + const family = model.family?.toLowerCase() + + // Check for pattern matches + for (const [pattern, tokens] of Object.entries(minTokens)) { + if (pattern === "default") continue + if (modelID.includes(pattern) || (family && family.includes(pattern))) { + return tokens + } + } + + return minTokens.default + } + + /** + * Detect effective provider for passthrough providers like OpenRouter + */ + export function detectEffectiveProvider(model: Provider.Model): string { + if (model.providerID !== "openrouter") return model.providerID + + const apiID = model.api.id.toLowerCase() + + // Detect underlying provider from model ID + if (apiID.includes("anthropic/") || apiID.includes("claude")) return "anthropic" + if (apiID.includes("openai/") || apiID.includes("gpt")) return "openai" + if (apiID.includes("google/") || apiID.includes("gemini")) return "google" + if (apiID.includes("deepseek/")) return "deepseek" + if (apiID.includes("mistral/")) return "mistral" + + return model.providerID + } + + /** + * Merge user config with defaults + */ + function mergeConfig(base: Config, user?: UserConfig): Config { + if (!user) return base + + return { + cache: { + ...base.cache, + ...(user.cache?.enabled !== undefined && { enabled: user.cache.enabled }), + ...(user.cache?.ttl !== undefined && { ttl: user.cache.ttl }), + ...(user.cache?.minTokens !== undefined && { minTokens: user.cache.minTokens }), + ...(user.cache?.maxBreakpoints !== undefined && { maxBreakpoints: user.cache.maxBreakpoints }), + }, + promptOrder: { + ...base.promptOrder, + ...(user.promptOrder?.ordering && { ordering: user.promptOrder.ordering }), + ...(user.promptOrder?.cacheBreakpoints && { cacheBreakpoints: user.promptOrder.cacheBreakpoints }), + }, + } + } + + /** + * Get configuration for a provider with optional model and agent overrides + * + * @param providerID - The provider identifier + * @param model - Optional model for minTokens resolution + * @param agentID - Optional agent identifier for agent-specific overrides + * @param userProviderConfig - Optional user provider configuration + * @param userAgentConfig - Optional user agent configuration + */ + export function getConfig( + providerID: string, + model?: Provider.Model, + agentID?: string, + userProviderConfig?: UserConfig, + userAgentConfig?: UserConfig, + ): Config { + // Get provider defaults + const providerDefaults = defaults[providerID] ?? defaults.default + + // Apply user provider config + let config = mergeConfig(providerDefaults, userProviderConfig) + + // Apply user agent config (highest priority) + if (agentID && userAgentConfig) { + config = mergeConfig(config, userAgentConfig) + } + + // Resolve minTokens for the specific model + if (model && typeof config.cache.minTokens !== "number") { + config = { + ...config, + cache: { + ...config.cache, + minTokens: resolveMinTokens(config.cache.minTokens, model), + }, + } + } + + return config + } + + /** + * Get the prompt ordering for a provider + * @param model - The model to get ordering for + * @param agentID - Optional agent ID for agent-specific overrides + * @param userConfig - User config that can override prompt ordering + */ + export function getPromptOrdering(model: Provider.Model, agentID?: string, userConfig?: UserConfig): PromptSection[] { + // When userConfig is provided without agentID, use it as provider config + // When both are provided, use userConfig as agent config + const providerConfig = agentID ? undefined : userConfig + const agentConfig = agentID ? userConfig : undefined + const config = getConfig(model.providerID, model, agentID, providerConfig, agentConfig) + return config.promptOrder.ordering + } + + /** + * Check if a provider supports explicit cache breakpoints + */ + export function supportsExplicitCaching(providerID: string): boolean { + const config = defaults[providerID] ?? defaults.default + return config.cache.type === "explicit-breakpoint" || config.cache.type === "passthrough" + } + + /** + * Get the cache control property name for a provider + */ + export function getCacheProperty(providerID: string): string | null { + const config = defaults[providerID] ?? defaults.default + return config.cache.property + } + + /** + * Check if caching is enabled for a provider + */ + export function isCachingEnabled(providerID: string, model?: Provider.Model, userConfig?: UserConfig): boolean { + const config = getConfig(providerID, model, undefined, userConfig) + return config.cache.enabled + } + + /** + * Get provider options key for cache control based on npm package + */ + export function getProviderOptionsKey(npm: string, providerID: string): string { + switch (npm) { + case "@ai-sdk/anthropic": + return "anthropic" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@openrouter/ai-sdk-provider": + return "openrouter" + case "@ai-sdk/openai-compatible": + return "openaiCompatible" + default: + return providerID + } + } + + /** + * Build cache control object for a specific provider + */ + export function buildCacheControl(providerID: string, ttl: CacheTTL = "5m"): Record { + const config = defaults[providerID] ?? defaults.default + + if (!config.cache.property) return {} + + // Build the cache control value based on provider + switch (config.cache.type) { + case "explicit-breakpoint": + // Anthropic/Bedrock style + if (ttl === "1h") { + // Extended cache (if supported) + return { type: "ephemeral" } // Currently only ephemeral is widely supported + } + return { type: "ephemeral" } + + case "passthrough": + // OpenRouter style + return { type: "ephemeral" } + + default: + return {} + } + } + + /** + * Convert user config from Config module format to ProviderConfig.UserConfig format + * + * This bridges the gap between the user-facing config schema (in config/config.ts) + * and the internal ProviderConfig format. Works for both provider and agent configs. + * + * @param providerConfig - Config from Config.get().provider[providerID] or Config.get().agent[agentID] + */ + export function fromUserProviderConfig(providerConfig?: { + cache?: { + enabled?: boolean + ttl?: CacheTTL + minTokens?: number + maxBreakpoints?: number + } + promptOrder?: { + ordering?: PromptSection[] + cacheBreakpoints?: PromptSection[] + } + }): UserConfig | undefined { + if (!providerConfig) return undefined + if (!providerConfig.cache && !providerConfig.promptOrder) return undefined + + const result: UserConfig = {} + + if (providerConfig.cache) { + result.cache = {} + if (providerConfig.cache.enabled !== undefined) result.cache.enabled = providerConfig.cache.enabled + if (providerConfig.cache.ttl !== undefined) result.cache.ttl = providerConfig.cache.ttl + if (providerConfig.cache.minTokens !== undefined) result.cache.minTokens = providerConfig.cache.minTokens + if (providerConfig.cache.maxBreakpoints !== undefined) + result.cache.maxBreakpoints = providerConfig.cache.maxBreakpoints + } + + if (providerConfig.promptOrder) { + result.promptOrder = {} + if (providerConfig.promptOrder.ordering) result.promptOrder.ordering = providerConfig.promptOrder.ordering + if (providerConfig.promptOrder.cacheBreakpoints) + result.promptOrder.cacheBreakpoints = providerConfig.promptOrder.cacheBreakpoints + } + + return result + } +} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c0ee452365f..d6c33063984 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -3,6 +3,7 @@ import { unique } from "remeda" import type { JSONSchema } from "zod/v4/core" import type { Provider } from "./provider" import type { ModelsDev } from "./models" +import { ProviderConfig } from "./config" type Modality = NonNullable["input"][number] @@ -122,27 +123,90 @@ export namespace ProviderTransform { return msgs } - function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] { + /** + * Build provider options object for cache control based on provider type + */ + function buildCacheProviderOptions(model: Provider.Model): Record> { + return buildCacheOptionsInternal(model) + } + + /** + * Build cache control options for tools (exported for use in prompt.ts) + */ + export function buildToolCacheOptions(model: Provider.Model): Record> { + return buildCacheOptionsInternal(model) + } + + /** + * Internal function to build cache control options + */ + function buildCacheOptionsInternal(model: Provider.Model): Record> { + const config = ProviderConfig.getConfig(model.providerID, model) + + if (!config.cache.enabled || !config.cache.property) return {} + + const cacheValue = ProviderConfig.buildCacheControl(model.providerID, config.cache.ttl) + if (!Object.keys(cacheValue).length) return {} + + // Build provider options for all supported providers + // This ensures cache control works regardless of which provider is being used + const result: Record> = {} + + // Always include anthropic cacheControl for Claude models + if (model.api.id.includes("claude") || model.providerID === "anthropic") { + result.anthropic = { cacheControl: cacheValue } + } + + // Include bedrock cachePoint for Bedrock provider + if (model.providerID === "amazon-bedrock" || model.api.npm === "@ai-sdk/amazon-bedrock") { + result.bedrock = { cachePoint: cacheValue } + } + + // Include openrouter cache_control for OpenRouter + if (model.providerID === "openrouter" || model.api.npm === "@openrouter/ai-sdk-provider") { + result.openrouter = { cache_control: cacheValue } + } + + // Include openaiCompatible for compatible providers + if (model.api.npm?.includes("openai-compatible")) { + result.openaiCompatible = { cache_control: cacheValue } + } + + // Include google-vertex-anthropic + if (model.providerID === "google-vertex-anthropic") { + result.anthropic = { cacheControl: cacheValue } + } + + return result + } + + /** + * Apply caching based on provider configuration + * Uses ProviderConfig to determine caching strategy + */ + function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + const config = ProviderConfig.getConfig(model.providerID, model) + + // Skip if caching is disabled or provider doesn't support explicit caching + if (!config.cache.enabled) return msgs + if (config.cache.type !== "explicit-breakpoint" && config.cache.type !== "passthrough") return msgs + + const providerOptions = buildCacheProviderOptions(model) + if (!Object.keys(providerOptions).length) return msgs + + // Get messages to cache based on hierarchy const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) - const providerOptions = { - anthropic: { - cacheControl: { type: "ephemeral" }, - }, - openrouter: { - cache_control: { type: "ephemeral" }, - }, - bedrock: { - cachePoint: { type: "ephemeral" }, - }, - openaiCompatible: { - cache_control: { type: "ephemeral" }, - }, - } + // Track breakpoints applied (respect maxBreakpoints) + let breakpointsApplied = 0 + const maxBreakpoints = config.cache.maxBreakpoints for (const msg of unique([...system, ...final])) { - const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0 + if (maxBreakpoints > 0 && breakpointsApplied >= maxBreakpoints) break + + const shouldUseContentOptions = + model.providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { const lastContent = msg.content[msg.content.length - 1] @@ -151,6 +215,7 @@ export namespace ProviderTransform { ...lastContent.providerOptions, ...providerOptions, } + breakpointsApplied++ continue } } @@ -159,6 +224,7 @@ export namespace ProviderTransform { ...msg.providerOptions, ...providerOptions, } + breakpointsApplied++ } return msgs @@ -191,8 +257,11 @@ export namespace ProviderTransform { export function message(msgs: ModelMessage[], model: Provider.Model) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model) - if (model.providerID === "anthropic" || model.api.id.includes("anthropic") || model.api.id.includes("claude")) { - msgs = applyCaching(msgs, model.providerID) + + // Use ProviderConfig to determine if caching should be applied + const config = ProviderConfig.getConfig(model.providerID, model) + if (config.cache.enabled && ProviderConfig.supportsExplicitCaching(model.providerID)) { + msgs = applyCaching(msgs, model) } return msgs diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7f1b03c94a8..75ffe39a9a8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,6 +23,7 @@ import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" +import { ProviderConfig } from "../provider/config" import { SystemPrompt } from "./system" import { Plugin } from "../plugin" @@ -477,7 +478,12 @@ export namespace SessionPrompt { model, abort, }) - const system = await resolveSystemPrompt({ + // Get user configs for provider-specific caching + const userProviderConfig = ProviderConfig.fromUserProviderConfig(cfg.provider?.[model.providerID]) + const userAgentConfig = ProviderConfig.fromUserProviderConfig(cfg.agent?.[agent.name]) + + // Resolve system prompt sections for provider-specific ordering + const systemSections = await resolveSystemPromptSections({ model, agent, system: lastUser.system, @@ -489,6 +495,8 @@ export namespace SessionPrompt { model, tools: lastUser.tools, processor, + userProviderConfig, + userAgentConfig, }) const provider = await Provider.getProvider(model.providerID) const params = await Plugin.trigger( @@ -540,13 +548,10 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + // Build messages with provider-specific system prompt ordering + const systemMessages = buildSystemMessages(systemSections, model, userProviderConfig, agent.name, userAgentConfig) const messages: ModelMessage[] = [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), + ...systemMessages, ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep ? [ @@ -624,14 +629,32 @@ export namespace SessionPrompt { } // Transform tool schemas for provider compatibility if (args.params.tools && Array.isArray(args.params.tools)) { - args.params.tools = args.params.tools.map((tool: any) => { + const providerConfig = ProviderConfig.getConfig( + model.providerID, + model, + agent.name, + userProviderConfig, + userAgentConfig, + ) + const shouldCacheTools = providerConfig.promptOrder.toolCaching && providerConfig.cache.enabled + + args.params.tools = args.params.tools.map((tool: any, index: number) => { // Tools at middleware level have inputSchema, not parameters if (tool.inputSchema && typeof tool.inputSchema === "object") { - // Transform the inputSchema for provider compatibility - return { + const transformed = { ...tool, inputSchema: ProviderTransform.schema(model, tool.inputSchema), } + + // Add cache control to the last tool for explicit breakpoint providers + if (shouldCacheTools && index === args.params.tools!.length - 1) { + transformed.providerOptions = { + ...transformed.providerOptions, + ...ProviderTransform.buildToolCacheOptions(model), + } + } + + return transformed } // If no inputSchema, return tool unchanged return tool @@ -672,39 +695,162 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveSystemPrompt(input: { + /** + * Prompt sections for provider-specific ordering + */ + interface PromptSections { + /** Provider header (e.g., anthropic spoof) - most stable */ + header: string[] + /** Agent/system instructions - stable per agent */ + instructions: string[] + /** Environment info (cwd, files, etc.) - stable per session */ + environment: string[] + /** Custom instructions (AGENTS.md, etc.) - stable per project */ + custom: string[] + /** Max steps warning if applicable */ + maxSteps: string[] + } + + /** + * Resolve system prompt into separate sections for provider-specific ordering + */ + async function resolveSystemPromptSections(input: { system?: string agent: Agent.Info model: Provider.Model isLastStep?: boolean - }) { - let system = SystemPrompt.header(input.model.providerID) - system.push( - ...(() => { - if (input.system) return [input.system] - if (input.agent.prompt) return [input.agent.prompt] - return SystemPrompt.provider(input.model) - })(), - ) - system.push(...(await SystemPrompt.environment())) - system.push(...(await SystemPrompt.custom())) + }): Promise { + const header = SystemPrompt.header(input.model.providerID) + const instructions = (() => { + if (input.system) return [input.system] + if (input.agent.prompt) return [input.agent.prompt] + return SystemPrompt.provider(input.model) + })() + const environment = await SystemPrompt.environment() + const custom = await SystemPrompt.custom() + const maxSteps = input.isLastStep ? [MAX_STEPS] : [] + + return { header, instructions, environment, custom, maxSteps } + } + + /** + * Build system messages with provider-specific ordering and caching + * + * This function respects: + * - Provider-specific prompt ordering for cache efficiency + * - combineSystemMessages: whether to merge all system content into one message + * - User provider config overrides (from opencode.json provider section) + * - User agent config overrides (from opencode.json agent section) + * + * Config priority (highest last): Provider defaults → User provider config → User agent config + */ + function buildSystemMessages( + sections: PromptSections, + model: Provider.Model, + userProviderConfig?: ProviderConfig.UserConfig, + agentID?: string, + userAgentConfig?: ProviderConfig.UserConfig, + ): ModelMessage[] { + const config = ProviderConfig.getConfig(model.providerID, model, agentID, userProviderConfig, userAgentConfig) + const ordering = config.promptOrder.ordering + const combineSystemMessages = config.promptOrder.combineSystemMessages + + // Map section names to content + // Note: header is NOT included here - it must always come first (e.g., "You are Claude Code...") + const sectionContent: Record = { + instructions: sections.instructions, + environment: sections.environment, + system: sections.custom, + // tools and messages are handled separately + tools: [], + messages: [], + } + + // Build ordered system content - header MUST come first (required by some APIs) + const orderedContent: string[] = [...sections.header] + for (const section of ordering) { + if (section === "tools" || section === "messages") continue + const content = sectionContent[section] + if (content?.length) { + orderedContent.push(...content) + } + } - if (input.isLastStep) { - system.push(MAX_STEPS) + // Add max steps if present + if (sections.maxSteps.length) { + orderedContent.push(...sections.maxSteps) } - // max 2 system prompt messages for caching purposes - const [first, ...rest] = system - system = [first, rest.join("\n")] - return system + // If no content, return empty + if (!orderedContent.length) return [] + + // Most providers require a single system message + if (combineSystemMessages) { + return [ + { + role: "system", + content: orderedContent.join("\n\n"), + }, + ] + } + + // For providers that support multiple system messages (Anthropic, Bedrock), + // split into: 1) header alone, 2) everything else + // This matches the original behavior where header is always the first system message + // (required for API authorization, e.g., "You are Claude Code...") + const result: ModelMessage[] = [] + + // Header MUST be its own message first + if (sections.header.length) { + result.push({ + role: "system", + content: sections.header.join("\n"), + }) + } + + // Build remaining content in order + const remainingContent: string[] = [] + for (const section of ordering) { + if (section === "tools" || section === "messages") continue + const content = sectionContent[section] + if (content?.length) { + remainingContent.push(...content) + } + } + + // Add max steps if present + if (sections.maxSteps.length) { + remainingContent.push(...sections.maxSteps) + } + + // Add remaining content as second message + if (remainingContent.length) { + result.push({ + role: "system", + content: remainingContent.join("\n"), + }) + } + + return result } + /** + * Resolve tools for the current session with provider-specific optimizations + * + * This function: + * - Sorts tools alphabetically when sortTools is enabled (for cache consistency) + * - Respects user provider and agent config overrides + * + * Config priority (highest last): Provider defaults → User provider config → User agent config + */ async function resolveTools(input: { agent: Agent.Info model: Provider.Model sessionID: string tools?: Record processor: SessionProcessor.Info + userProviderConfig?: ProviderConfig.UserConfig + userAgentConfig?: ProviderConfig.UserConfig }) { const tools: Record = {} const enabledTools = pipe( @@ -712,7 +858,24 @@ export namespace SessionPrompt { mergeDeep(await ToolRegistry.enabled(input.agent)), mergeDeep(input.tools ?? {}), ) - for (const item of await ToolRegistry.tools(input.model.providerID)) { + + // Get provider config for tool ordering and caching + const config = ProviderConfig.getConfig( + input.model.providerID, + input.model, + input.agent.name, + input.userProviderConfig, + input.userAgentConfig, + ) + const shouldSortTools = config.promptOrder.sortTools + + // Get registry tools and optionally sort them for cache consistency + let registryTools = await ToolRegistry.tools(input.model.providerID) + if (shouldSortTools) { + registryTools = [...registryTools].sort((a, b) => a.id.localeCompare(b.id)) + } + + for (const item of registryTools) { if (Wildcard.all(item.id, enabledTools) === false) continue const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ @@ -776,7 +939,13 @@ export namespace SessionPrompt { }) } - for (const [key, item] of Object.entries(await MCP.tools())) { + // Get MCP tools and optionally sort them for cache consistency + let mcpToolEntries = Object.entries(await MCP.tools()) + if (shouldSortTools) { + mcpToolEntries = mcpToolEntries.sort(([a], [b]) => a.localeCompare(b)) + } + + for (const [key, item] of mcpToolEntries) { if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute if (!execute) continue diff --git a/packages/opencode/test/provider/config.test.ts b/packages/opencode/test/provider/config.test.ts new file mode 100644 index 00000000000..b97e8562824 --- /dev/null +++ b/packages/opencode/test/provider/config.test.ts @@ -0,0 +1,934 @@ +import { describe, expect, test } from "bun:test" +import { ProviderConfig } from "../../src/provider/config" +import type { Provider } from "../../src/provider/provider" + +// Helper to create mock models for testing +function createMockModel(providerID: string, modelID: string, family?: string): Provider.Model { + return { + id: modelID, + providerID, + family, + api: { + id: modelID, + url: `https://api.${providerID}.com`, + npm: `@ai-sdk/${providerID}`, + }, + name: modelID, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: 100000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + } +} + +describe("ProviderConfig.defaults", () => { + // ============================================ + // EXPLICIT BREAKPOINT PROVIDERS + // ============================================ + + describe("anthropic", () => { + const config = ProviderConfig.defaults.anthropic + + test("should have explicit-breakpoint cache type", () => { + expect(config.cache.type).toBe("explicit-breakpoint") + }) + + test("should use cacheControl property", () => { + expect(config.cache.property).toBe("cacheControl") + }) + + test("should have tools→system→messages hierarchy", () => { + expect(config.cache.hierarchy).toEqual(["tools", "system", "messages"]) + }) + + test("should have 5m default TTL", () => { + expect(config.cache.ttl).toBe("5m") + }) + + test("should have maxBreakpoints of 4", () => { + expect(config.cache.maxBreakpoints).toBe(4) + }) + + test("should be enabled", () => { + expect(config.cache.enabled).toBe(true) + }) + + test("should have tools-first ordering", () => { + expect(config.promptOrder.ordering[0]).toBe("tools") + }) + + test("should cache tools, system, and messages", () => { + expect(config.promptOrder.cacheBreakpoints).toContain("tools") + expect(config.promptOrder.cacheBreakpoints).toContain("system") + expect(config.promptOrder.cacheBreakpoints).toContain("messages") + }) + + test("should not combine system messages (supports multiple for caching)", () => { + expect(config.promptOrder.combineSystemMessages).toBe(false) + }) + }) + + describe("amazon-bedrock", () => { + const config = ProviderConfig.defaults["amazon-bedrock"] + + test("should have explicit-breakpoint cache type", () => { + expect(config.cache.type).toBe("explicit-breakpoint") + }) + + test("should use cachePoint property (different from anthropic)", () => { + expect(config.cache.property).toBe("cachePoint") + }) + + test("should have system→messages→tools hierarchy (different from Anthropic)", () => { + expect(config.cache.hierarchy).toEqual(["system", "messages", "tools"]) + }) + + test("should have system-first ordering", () => { + expect(config.promptOrder.ordering[0]).toBe("system") + }) + + test("should have tools at end of ordering", () => { + const ordering = config.promptOrder.ordering + expect(ordering[ordering.length - 1]).toBe("tools") + }) + }) + + describe("google-vertex-anthropic", () => { + const config = ProviderConfig.defaults["google-vertex-anthropic"] + + test("should have explicit-breakpoint cache type (Anthropic-style)", () => { + expect(config.cache.type).toBe("explicit-breakpoint") + }) + + test("should use cacheControl property (same as Anthropic)", () => { + expect(config.cache.property).toBe("cacheControl") + }) + + test("should have same hierarchy as Anthropic", () => { + expect(config.cache.hierarchy).toEqual(ProviderConfig.defaults.anthropic.cache.hierarchy) + }) + }) + + // ============================================ + // AUTOMATIC PREFIX PROVIDERS + // ============================================ + + describe("openai", () => { + const config = ProviderConfig.defaults.openai + + test("should have automatic-prefix cache type", () => { + expect(config.cache.type).toBe("automatic-prefix") + }) + + test("should have null property (automatic caching)", () => { + expect(config.cache.property).toBeNull() + }) + + test("should have empty hierarchy (prefix-based)", () => { + expect(config.cache.hierarchy).toEqual([]) + }) + + test("should have auto TTL", () => { + expect(config.cache.ttl).toBe("auto") + }) + + test("should have 1024 minTokens", () => { + expect(config.cache.minTokens).toBe(1024) + }) + + test("should have instructions-first ordering (static content first)", () => { + expect(config.promptOrder.ordering[0]).toBe("instructions") + }) + + test("should have no cache breakpoints (automatic)", () => { + expect(config.promptOrder.cacheBreakpoints).toEqual([]) + }) + + test("should combine system messages", () => { + expect(config.promptOrder.combineSystemMessages).toBe(true) + }) + }) + + describe("azure", () => { + const config = ProviderConfig.defaults.azure + + test("should match openai config type (same API)", () => { + expect(config.cache.type).toBe(ProviderConfig.defaults.openai.cache.type) + }) + + test("should match openai ordering", () => { + expect(config.promptOrder.ordering).toEqual(ProviderConfig.defaults.openai.promptOrder.ordering) + }) + }) + + describe("github-copilot", () => { + const config = ProviderConfig.defaults["github-copilot"] + + test("should have automatic-prefix cache type", () => { + expect(config.cache.type).toBe("automatic-prefix") + }) + + test("should match openai ordering (OpenAI-compatible)", () => { + expect(config.promptOrder.ordering).toEqual(ProviderConfig.defaults.openai.promptOrder.ordering) + }) + }) + + describe("deepseek", () => { + const config = ProviderConfig.defaults.deepseek + + test("should have automatic-prefix cache type", () => { + expect(config.cache.type).toBe("automatic-prefix") + }) + + test("should have 0 minTokens (no minimum)", () => { + expect(config.cache.minTokens).toBe(0) + }) + }) + + // ============================================ + // IMPLICIT CACHING PROVIDERS + // ============================================ + + describe("google", () => { + const config = ProviderConfig.defaults.google + + test("should have implicit cache type", () => { + expect(config.cache.type).toBe("implicit") + }) + + test("should have null property (implicit caching)", () => { + expect(config.cache.property).toBeNull() + }) + + test("should have system-first ordering", () => { + expect(config.promptOrder.ordering[0]).toBe("system") + }) + + test("should combine system messages for systemInstruction", () => { + expect(config.promptOrder.combineSystemMessages).toBe(true) + }) + }) + + // ============================================ + // PASSTHROUGH PROVIDERS + // ============================================ + + describe("openrouter", () => { + const config = ProviderConfig.defaults.openrouter + + test("should have passthrough cache type", () => { + expect(config.cache.type).toBe("passthrough") + }) + + test("should use cache_control property", () => { + expect(config.cache.property).toBe("cache_control") + }) + + test("should have tools-first ordering (optimized for Anthropic)", () => { + expect(config.promptOrder.ordering[0]).toBe("tools") + }) + + test("should cache tools and system", () => { + expect(config.promptOrder.cacheBreakpoints).toContain("tools") + expect(config.promptOrder.cacheBreakpoints).toContain("system") + }) + }) + + // ============================================ + // NO CACHING PROVIDERS + // ============================================ + + describe("mistral", () => { + const config = ProviderConfig.defaults.mistral + + test("should have caching disabled", () => { + expect(config.cache.enabled).toBe(false) + }) + + test("should have none cache type", () => { + expect(config.cache.type).toBe("none") + }) + + test("should have standard ordering", () => { + expect(config.promptOrder.ordering[0]).toBe("system") + }) + + test("should have no cache breakpoints", () => { + expect(config.promptOrder.cacheBreakpoints).toEqual([]) + }) + }) + + describe("qwen", () => { + test("should have caching disabled", () => { + expect(ProviderConfig.defaults.qwen.cache.enabled).toBe(false) + }) + }) + + describe("cerebras", () => { + test("should have caching disabled", () => { + expect(ProviderConfig.defaults.cerebras.cache.enabled).toBe(false) + }) + }) + + // ============================================ + // DEFAULT FALLBACK + // ============================================ + + describe("default", () => { + const config = ProviderConfig.defaults.default + + test("should have caching disabled", () => { + expect(config.cache.enabled).toBe(false) + }) + + test("should have none cache type", () => { + expect(config.cache.type).toBe("none") + }) + + test("should have standard ordering", () => { + expect(config.promptOrder.ordering).toEqual(["system", "instructions", "environment", "tools", "messages"]) + }) + }) + + // ============================================ + // ALL PROVIDERS VALIDATION + // ============================================ + + describe("all providers", () => { + const allProviders = Object.keys(ProviderConfig.defaults) + + test.each(allProviders)("%s should have valid cache config", (providerID) => { + const config = ProviderConfig.defaults[providerID] + expect(config.cache).toBeDefined() + expect(config.cache.enabled).toBeDefined() + expect(config.cache.type).toBeDefined() + expect(["explicit-breakpoint", "automatic-prefix", "implicit", "passthrough", "none"]).toContain( + config.cache.type, + ) + }) + + test.each(allProviders)("%s should have valid promptOrder config", (providerID) => { + const config = ProviderConfig.defaults[providerID] + expect(config.promptOrder).toBeDefined() + expect(config.promptOrder.ordering).toBeDefined() + expect(Array.isArray(config.promptOrder.ordering)).toBe(true) + expect(config.promptOrder.cacheBreakpoints).toBeDefined() + expect(Array.isArray(config.promptOrder.cacheBreakpoints)).toBe(true) + expect(typeof config.promptOrder.combineSystemMessages).toBe("boolean") + }) + + test.each(allProviders)("%s ordering should contain all required sections", (providerID) => { + const ordering = ProviderConfig.defaults[providerID].promptOrder.ordering + expect(ordering).toContain("system") + expect(ordering).toContain("messages") + expect(ordering).toContain("tools") + }) + + test.each(allProviders)("%s cacheBreakpoints should be subset of ordering", (providerID) => { + const { ordering, cacheBreakpoints } = ProviderConfig.defaults[providerID].promptOrder + for (const breakpoint of cacheBreakpoints) { + expect(ordering).toContain(breakpoint) + } + }) + }) +}) + +describe("ProviderConfig.resolveMinTokens", () => { + test("should return number directly if minTokens is a number", () => { + const result = ProviderConfig.resolveMinTokens(1024, undefined) + expect(result).toBe(1024) + }) + + test("should return default for unknown model", () => { + const minTokens = { + "claude-opus-4": 4096, + default: 1024, + } + const model = createMockModel("anthropic", "unknown-model") + const result = ProviderConfig.resolveMinTokens(minTokens, model) + expect(result).toBe(1024) + }) + + test("should match model ID pattern", () => { + const minTokens = { + "claude-opus-4": 4096, + "claude-haiku-3.5": 2048, + default: 1024, + } + const model = createMockModel("anthropic", "claude-opus-4-20250514") + const result = ProviderConfig.resolveMinTokens(minTokens, model) + expect(result).toBe(4096) + }) + + test("should match model family", () => { + const minTokens = { + "claude-opus-4": 4096, + default: 1024, + } + const model = createMockModel("anthropic", "some-model", "claude-opus-4") + const result = ProviderConfig.resolveMinTokens(minTokens, model) + expect(result).toBe(4096) + }) + + test("should return default when no model provided", () => { + const minTokens = { + "claude-opus-4": 4096, + default: 1024, + } + const result = ProviderConfig.resolveMinTokens(minTokens, undefined) + expect(result).toBe(1024) + }) +}) + +describe("ProviderConfig.detectEffectiveProvider", () => { + test("should return original provider for non-openrouter", () => { + const model = createMockModel("anthropic", "claude-3.5-sonnet") + expect(ProviderConfig.detectEffectiveProvider(model)).toBe("anthropic") + }) + + test("should detect anthropic for openrouter with claude model", () => { + const model = createMockModel("openrouter", "anthropic/claude-3.5-sonnet") + expect(ProviderConfig.detectEffectiveProvider(model)).toBe("anthropic") + }) + + test("should detect openai for openrouter with gpt model", () => { + const model = createMockModel("openrouter", "openai/gpt-4") + expect(ProviderConfig.detectEffectiveProvider(model)).toBe("openai") + }) + + test("should detect google for openrouter with gemini model", () => { + const model = createMockModel("openrouter", "google/gemini-2.5-flash") + expect(ProviderConfig.detectEffectiveProvider(model)).toBe("google") + }) + + test("should return openrouter for unknown model", () => { + const model = createMockModel("openrouter", "unknown/some-model") + expect(ProviderConfig.detectEffectiveProvider(model)).toBe("openrouter") + }) +}) + +describe("ProviderConfig.getConfig", () => { + describe("provider defaults only", () => { + test("should return anthropic defaults for anthropic provider", () => { + const config = ProviderConfig.getConfig("anthropic") + expect(config.cache.type).toBe("explicit-breakpoint") + expect(config.cache.property).toBe("cacheControl") + }) + + test("should return openai defaults for openai provider", () => { + const config = ProviderConfig.getConfig("openai") + expect(config.cache.type).toBe("automatic-prefix") + }) + + test("should return default config for unknown provider", () => { + const config = ProviderConfig.getConfig("unknown-provider") + expect(config.cache.enabled).toBe(false) + expect(config.cache.type).toBe("none") + }) + }) + + describe("user provider config overrides", () => { + test("should merge user provider config with defaults", () => { + const userConfig: ProviderConfig.UserConfig = { + cache: { + ttl: "1h", + }, + } + const config = ProviderConfig.getConfig("anthropic", undefined, undefined, userConfig) + expect(config.cache.ttl).toBe("1h") // overridden + expect(config.cache.type).toBe("explicit-breakpoint") // from defaults + expect(config.cache.property).toBe("cacheControl") // from defaults + }) + + test("should override minTokens when specified", () => { + const userConfig: ProviderConfig.UserConfig = { + cache: { + minTokens: 2048, + }, + } + const config = ProviderConfig.getConfig("anthropic", undefined, undefined, userConfig) + expect(config.cache.minTokens).toBe(2048) + }) + + test("should disable caching when enabled: false", () => { + const userConfig: ProviderConfig.UserConfig = { + cache: { + enabled: false, + }, + } + const config = ProviderConfig.getConfig("anthropic", undefined, undefined, userConfig) + expect(config.cache.enabled).toBe(false) + }) + }) + + describe("user agent config overrides", () => { + test("should apply agent-specific TTL", () => { + const agentConfig: ProviderConfig.UserConfig = { + cache: { + ttl: "1h", + }, + } + const config = ProviderConfig.getConfig("anthropic", undefined, "task", undefined, agentConfig) + expect(config.cache.ttl).toBe("1h") + }) + + test("should disable caching for specific agent", () => { + const agentConfig: ProviderConfig.UserConfig = { + cache: { + enabled: false, + }, + } + const config = ProviderConfig.getConfig("anthropic", undefined, "title", undefined, agentConfig) + expect(config.cache.enabled).toBe(false) + }) + }) + + describe("full config hierarchy", () => { + test("should apply hierarchy: defaults < provider < agent", () => { + const providerConfig: ProviderConfig.UserConfig = { + cache: { + ttl: "5m", + minTokens: 2048, + }, + } + const agentConfig: ProviderConfig.UserConfig = { + cache: { + ttl: "1h", // overrides provider + }, + } + const config = ProviderConfig.getConfig("anthropic", undefined, "task", providerConfig, agentConfig) + expect(config.cache.ttl).toBe("1h") // from agent + expect(config.cache.minTokens).toBe(2048) // from provider + expect(config.cache.type).toBe("explicit-breakpoint") // from defaults + }) + }) + + describe("model-specific resolution", () => { + test("should resolve minTokens for claude-opus-4", () => { + const model = createMockModel("anthropic", "claude-opus-4-20250514") + const config = ProviderConfig.getConfig("anthropic", model) + expect(config.cache.minTokens).toBe(4096) + }) + + test("should resolve minTokens for claude-haiku-3.5", () => { + const model = createMockModel("anthropic", "claude-3-5-haiku-20241022") + const config = ProviderConfig.getConfig("anthropic", model) + expect(config.cache.minTokens).toBe(2048) + }) + + test("should use default minTokens for unknown model", () => { + const model = createMockModel("anthropic", "claude-unknown-model") + const config = ProviderConfig.getConfig("anthropic", model) + expect(config.cache.minTokens).toBe(1024) + }) + }) +}) + +describe("ProviderConfig.supportsExplicitCaching", () => { + test("should return true for anthropic", () => { + expect(ProviderConfig.supportsExplicitCaching("anthropic")).toBe(true) + }) + + test("should return true for amazon-bedrock", () => { + expect(ProviderConfig.supportsExplicitCaching("amazon-bedrock")).toBe(true) + }) + + test("should return true for openrouter (passthrough)", () => { + expect(ProviderConfig.supportsExplicitCaching("openrouter")).toBe(true) + }) + + test("should return false for openai", () => { + expect(ProviderConfig.supportsExplicitCaching("openai")).toBe(false) + }) + + test("should return false for google", () => { + expect(ProviderConfig.supportsExplicitCaching("google")).toBe(false) + }) + + test("should return false for mistral", () => { + expect(ProviderConfig.supportsExplicitCaching("mistral")).toBe(false) + }) + + test("should return false for unknown provider", () => { + expect(ProviderConfig.supportsExplicitCaching("unknown")).toBe(false) + }) +}) + +describe("ProviderConfig.getCacheProperty", () => { + test("should return cacheControl for anthropic", () => { + expect(ProviderConfig.getCacheProperty("anthropic")).toBe("cacheControl") + }) + + test("should return cachePoint for bedrock", () => { + expect(ProviderConfig.getCacheProperty("amazon-bedrock")).toBe("cachePoint") + }) + + test("should return cache_control for openrouter", () => { + expect(ProviderConfig.getCacheProperty("openrouter")).toBe("cache_control") + }) + + test("should return null for openai", () => { + expect(ProviderConfig.getCacheProperty("openai")).toBeNull() + }) +}) + +describe("ProviderConfig.isCachingEnabled", () => { + test("should return true for anthropic by default", () => { + expect(ProviderConfig.isCachingEnabled("anthropic")).toBe(true) + }) + + test("should return false for mistral", () => { + expect(ProviderConfig.isCachingEnabled("mistral")).toBe(false) + }) + + test("should return false when user disables caching", () => { + const userConfig: ProviderConfig.UserConfig = { + cache: { enabled: false }, + } + expect(ProviderConfig.isCachingEnabled("anthropic", undefined, userConfig)).toBe(false) + }) +}) + +describe("ProviderConfig.getProviderOptionsKey", () => { + test("should return anthropic for @ai-sdk/anthropic", () => { + expect(ProviderConfig.getProviderOptionsKey("@ai-sdk/anthropic", "anthropic")).toBe("anthropic") + }) + + test("should return bedrock for @ai-sdk/amazon-bedrock", () => { + expect(ProviderConfig.getProviderOptionsKey("@ai-sdk/amazon-bedrock", "amazon-bedrock")).toBe("bedrock") + }) + + test("should return openrouter for @openrouter/ai-sdk-provider", () => { + expect(ProviderConfig.getProviderOptionsKey("@openrouter/ai-sdk-provider", "openrouter")).toBe("openrouter") + }) + + test("should return providerID for unknown npm", () => { + expect(ProviderConfig.getProviderOptionsKey("@unknown/sdk", "custom")).toBe("custom") + }) +}) + +describe("ProviderConfig.buildCacheControl", () => { + test("should return ephemeral for anthropic", () => { + const result = ProviderConfig.buildCacheControl("anthropic") + expect(result).toEqual({ type: "ephemeral" }) + }) + + test("should return ephemeral for openrouter", () => { + const result = ProviderConfig.buildCacheControl("openrouter") + expect(result).toEqual({ type: "ephemeral" }) + }) + + test("should return empty object for openai", () => { + const result = ProviderConfig.buildCacheControl("openai") + expect(result).toEqual({}) + }) + + test("should return empty object for mistral", () => { + const result = ProviderConfig.buildCacheControl("mistral") + expect(result).toEqual({}) + }) +}) + +describe("ProviderConfig.getPromptOrdering", () => { + test("should return tools-first for anthropic", () => { + const model = createMockModel("anthropic", "claude-3.5-sonnet") + const ordering = ProviderConfig.getPromptOrdering(model) + expect(ordering[0]).toBe("tools") + }) + + test("should return system-first for bedrock", () => { + const model = createMockModel("amazon-bedrock", "anthropic.claude-3-sonnet") + const ordering = ProviderConfig.getPromptOrdering(model) + expect(ordering[0]).toBe("system") + }) + + test("should return instructions-first for openai", () => { + const model = createMockModel("openai", "gpt-4") + const ordering = ProviderConfig.getPromptOrdering(model) + expect(ordering[0]).toBe("instructions") + }) + + test("should use custom ordering from user config", () => { + const model = createMockModel("anthropic", "claude-3.5-sonnet") + const userConfig: ProviderConfig.UserConfig = { + promptOrder: { + ordering: ["system", "tools", "messages", "instructions", "environment"], + }, + } + const ordering = ProviderConfig.getPromptOrdering(model, undefined, userConfig) + expect(ordering[0]).toBe("system") + }) +}) + +// ============================================ +// PROMPT ORDER CONFIG TESTS +// ============================================ + +describe("ProviderConfig PromptOrderConfig fields", () => { + describe("systemPromptMode", () => { + test("anthropic uses parameter mode", () => { + const config = ProviderConfig.defaults.anthropic + expect(config.promptOrder.systemPromptMode).toBe("parameter") + }) + + test("openai uses role mode", () => { + const config = ProviderConfig.defaults.openai + expect(config.promptOrder.systemPromptMode).toBe("role") + }) + + test("google uses systemInstruction mode", () => { + const config = ProviderConfig.defaults.google + expect(config.promptOrder.systemPromptMode).toBe("systemInstruction") + }) + + test("amazon-bedrock uses parameter mode", () => { + const config = ProviderConfig.defaults["amazon-bedrock"] + expect(config.promptOrder.systemPromptMode).toBe("parameter") + }) + + test("azure uses role mode", () => { + const config = ProviderConfig.defaults.azure + expect(config.promptOrder.systemPromptMode).toBe("role") + }) + + test("mistral uses role mode", () => { + const config = ProviderConfig.defaults.mistral + expect(config.promptOrder.systemPromptMode).toBe("role") + }) + }) + + describe("toolCaching", () => { + test("anthropic supports tool caching", () => { + const config = ProviderConfig.defaults.anthropic + expect(config.promptOrder.toolCaching).toBe(true) + }) + + test("amazon-bedrock supports tool caching", () => { + const config = ProviderConfig.defaults["amazon-bedrock"] + expect(config.promptOrder.toolCaching).toBe(true) + }) + + test("google-vertex-anthropic supports tool caching", () => { + const config = ProviderConfig.defaults["google-vertex-anthropic"] + expect(config.promptOrder.toolCaching).toBe(true) + }) + + test("openrouter supports tool caching (passthrough)", () => { + const config = ProviderConfig.defaults.openrouter + expect(config.promptOrder.toolCaching).toBe(true) + }) + + test("openai does not support tool caching", () => { + const config = ProviderConfig.defaults.openai + expect(config.promptOrder.toolCaching).toBe(false) + }) + + test("google does not support tool caching", () => { + const config = ProviderConfig.defaults.google + expect(config.promptOrder.toolCaching).toBe(false) + }) + }) + + describe("requiresAlternatingRoles", () => { + test("anthropic requires alternating roles", () => { + const config = ProviderConfig.defaults.anthropic + expect(config.promptOrder.requiresAlternatingRoles).toBe(true) + }) + + test("google requires alternating roles", () => { + const config = ProviderConfig.defaults.google + expect(config.promptOrder.requiresAlternatingRoles).toBe(true) + }) + + test("openai does not require alternating roles", () => { + const config = ProviderConfig.defaults.openai + expect(config.promptOrder.requiresAlternatingRoles).toBe(false) + }) + + test("azure does not require alternating roles", () => { + const config = ProviderConfig.defaults.azure + expect(config.promptOrder.requiresAlternatingRoles).toBe(false) + }) + }) + + describe("sortTools", () => { + test("anthropic sorts tools for cache consistency", () => { + const config = ProviderConfig.defaults.anthropic + expect(config.promptOrder.sortTools).toBe(true) + }) + + test("openai sorts tools for prefix cache consistency", () => { + const config = ProviderConfig.defaults.openai + expect(config.promptOrder.sortTools).toBe(true) + }) + + test("google does not sort tools (implicit caching)", () => { + const config = ProviderConfig.defaults.google + expect(config.promptOrder.sortTools).toBe(false) + }) + + test("mistral does not sort tools (no caching)", () => { + const config = ProviderConfig.defaults.mistral + expect(config.promptOrder.sortTools).toBe(false) + }) + }) + + describe("combineSystemMessages", () => { + test("anthropic supports multiple system messages for cache breakpoints", () => { + const config = ProviderConfig.defaults.anthropic + expect(config.promptOrder.combineSystemMessages).toBe(false) + }) + + test("openai combines into single system message", () => { + const config = ProviderConfig.defaults.openai + expect(config.promptOrder.combineSystemMessages).toBe(true) + }) + + test("google combines into single system message", () => { + const config = ProviderConfig.defaults.google + expect(config.promptOrder.combineSystemMessages).toBe(true) + }) + + test("amazon-bedrock supports multiple system messages for cache breakpoints", () => { + const config = ProviderConfig.defaults["amazon-bedrock"] + expect(config.promptOrder.combineSystemMessages).toBe(false) + }) + }) +}) + +// ============================================ +// INTEGRATION TESTS +// ============================================ + +describe("ProviderConfig.fromUserProviderConfig", () => { + test("returns undefined for undefined input", () => { + expect(ProviderConfig.fromUserProviderConfig(undefined)).toBeUndefined() + }) + + test("returns undefined when no cache or promptOrder provided", () => { + expect(ProviderConfig.fromUserProviderConfig({})).toBeUndefined() + }) + + test("converts cache config correctly", () => { + const input = { + cache: { + enabled: false, + ttl: "1h" as const, + minTokens: 2048, + maxBreakpoints: 2, + }, + } + const result = ProviderConfig.fromUserProviderConfig(input) + expect(result?.cache?.enabled).toBe(false) + expect(result?.cache?.ttl).toBe("1h") + expect(result?.cache?.minTokens).toBe(2048) + expect(result?.cache?.maxBreakpoints).toBe(2) + }) + + test("converts promptOrder config correctly", () => { + const input = { + promptOrder: { + ordering: ["system" as const, "tools" as const, "messages" as const], + cacheBreakpoints: ["system" as const], + }, + } + const result = ProviderConfig.fromUserProviderConfig(input) + expect(result?.promptOrder?.ordering).toEqual(["system", "tools", "messages"]) + expect(result?.promptOrder?.cacheBreakpoints).toEqual(["system"]) + }) + + test("handles partial cache config", () => { + const input = { + cache: { + ttl: "5m" as const, + }, + } + const result = ProviderConfig.fromUserProviderConfig(input) + expect(result?.cache?.ttl).toBe("5m") + expect(result?.cache?.enabled).toBeUndefined() + expect(result?.cache?.minTokens).toBeUndefined() + }) + + test("converts both cache and promptOrder together", () => { + const input = { + cache: { + enabled: true, + ttl: "auto" as const, + }, + promptOrder: { + ordering: ["instructions" as const, "system" as const, "messages" as const], + }, + } + const result = ProviderConfig.fromUserProviderConfig(input) + expect(result?.cache?.enabled).toBe(true) + expect(result?.cache?.ttl).toBe("auto") + expect(result?.promptOrder?.ordering).toEqual(["instructions", "system", "messages"]) + }) +}) + +describe("ProviderConfig integration", () => { + describe("caching + ordering consistency", () => { + test("providers with tool caching should also support explicit caching", () => { + for (const [providerID, config] of Object.entries(ProviderConfig.defaults)) { + if (config.promptOrder.toolCaching) { + const supportsExplicit = ProviderConfig.supportsExplicitCaching(providerID) + expect(supportsExplicit).toBe(true) + } + } + }) + + test("providers that sort tools should have caching enabled", () => { + for (const [providerID, config] of Object.entries(ProviderConfig.defaults)) { + if (config.promptOrder.sortTools && providerID !== "default") { + expect(config.cache.enabled).toBe(true) + } + } + }) + }) + + describe("provider behavior consistency", () => { + test("all providers should have valid systemPromptMode", () => { + const validModes: ProviderConfig.SystemPromptMode[] = ["role", "parameter", "systemInstruction"] + for (const [providerID, config] of Object.entries(ProviderConfig.defaults)) { + expect(validModes).toContain(config.promptOrder.systemPromptMode) + } + }) + + test("all providers should have valid cache type", () => { + const validTypes: ProviderConfig.CacheType[] = [ + "explicit-breakpoint", + "automatic-prefix", + "implicit", + "passthrough", + "none", + ] + for (const [providerID, config] of Object.entries(ProviderConfig.defaults)) { + expect(validTypes).toContain(config.cache.type) + } + }) + + test("all providers should have valid TTL", () => { + const validTTLs: ProviderConfig.CacheTTL[] = ["5m", "1h", "auto"] + for (const [providerID, config] of Object.entries(ProviderConfig.defaults)) { + expect(validTTLs).toContain(config.cache.ttl) + } + }) + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4e202a63cb4..ad5d9afb9d0 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "../../src/provider/transform" +import type { Provider } from "../../src/provider/provider" const OUTPUT_TOKEN_MAX = 32000 @@ -259,3 +260,178 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) }) + +// Helper function to create a minimal model for testing +function createModel(overrides: Partial): Provider.Model { + return { + id: "test-model", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + ...overrides, + } +} + +describe("ProviderTransform.buildToolCacheOptions", () => { + describe("Anthropic provider", () => { + test("returns anthropic cacheControl for direct Anthropic", () => { + const model = createModel({ + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(result.anthropic).toBeDefined() + expect(result.anthropic.cacheControl).toEqual({ type: "ephemeral" }) + }) + + test("returns anthropic cacheControl for Claude models on other providers", () => { + const model = createModel({ + providerID: "openrouter", + api: { + id: "anthropic/claude-3-5-sonnet", + url: "https://openrouter.ai/api", + npm: "@openrouter/ai-sdk-provider", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(result.anthropic).toBeDefined() + expect(result.anthropic.cacheControl).toEqual({ type: "ephemeral" }) + expect(result.openrouter).toBeDefined() + expect(result.openrouter.cache_control).toEqual({ type: "ephemeral" }) + }) + }) + + describe("Amazon Bedrock provider", () => { + test("returns bedrock cachePoint for Bedrock", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-3-5-sonnet", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(result.bedrock).toBeDefined() + expect(result.bedrock.cachePoint).toEqual({ type: "ephemeral" }) + }) + }) + + describe("OpenRouter provider", () => { + test("returns openrouter cache_control for OpenRouter", () => { + const model = createModel({ + providerID: "openrouter", + api: { + id: "meta-llama/llama-3-70b", + url: "https://openrouter.ai/api", + npm: "@openrouter/ai-sdk-provider", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(result.openrouter).toBeDefined() + expect(result.openrouter.cache_control).toEqual({ type: "ephemeral" }) + }) + }) + + describe("Google Vertex Anthropic provider", () => { + test("returns anthropic cacheControl for Google Vertex Anthropic", () => { + const model = createModel({ + providerID: "google-vertex-anthropic", + api: { + id: "claude-3-5-sonnet", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex-anthropic", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(result.anthropic).toBeDefined() + expect(result.anthropic.cacheControl).toEqual({ type: "ephemeral" }) + }) + }) + + describe("Providers without explicit caching", () => { + test("returns empty object for OpenAI", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-4-turbo", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(Object.keys(result)).toHaveLength(0) + }) + + test("returns empty object for Google Gemini", () => { + const model = createModel({ + providerID: "google", + api: { + id: "gemini-2.5-pro", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(Object.keys(result)).toHaveLength(0) + }) + + test("returns empty object for Mistral", () => { + const model = createModel({ + providerID: "mistral", + api: { + id: "mistral-large", + url: "https://api.mistral.ai", + npm: "@ai-sdk/mistral", + }, + }) + + const result = ProviderTransform.buildToolCacheOptions(model) + + expect(Object.keys(result)).toHaveLength(0) + }) + }) +})