From 40234c786a2c1c4d5bd71f72fccae251066260ae Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 11:48:58 -0700 Subject: [PATCH 1/7] fix: [#1009] activate Snowflake Cortex prompt caching for Claude models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cortex's Chat Completions API only honors prompt caching for Claude models via block-level markers (`messages[].content[].cache_control`, `ephemeral`, max 4 breakpoints). `@ai-sdk/openai-compatible` serializes our cache providerOptions as message-level fields — on `system` messages, on single-text-part `user` messages (collapsed to string content), on `tool` messages, and into assistant `tool_calls` entries — all silently ignored by Cortex, so every request billed the full input rate (`cache_read_input`/`cache_write_input` stayed NULL in `TOKENS_GRANULAR`). - `relocateCacheControl`: move message-level markers into content blocks (string content is wrapped in a text block; array content attaches to the last non-empty block), strip stray markers from `tool_calls` entries, leave pre-existing block-level markers untouched, and enforce the 4-breakpoint limit keeping the trailing markers (longest prefixes). - Fallback: if Cortex rejects a cache-marked request with a 400, retry once with markers stripped via `stripCacheControl`; when the stripped retry succeeds, disable caching for the rest of the session (`cacheControl` param on `transformSnowflakeBody` strips all markers when disabled). - Contract test drives `ProviderTransform.applyCaching` through the real `@ai-sdk/openai-compatible` serialization and the plugin transform. - Env-gated live e2e tests: cache activity via `usage.prompt_tokens_details.cached_tokens`, and acceptance of `role:"tool"` messages with array content carrying markers. - Docs + CHANGELOG entries. Closes #1009 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + docs/docs/configure/providers.md | 2 + .../opencode/src/altimate/plugin/snowflake.ts | 141 ++++++- .../altimate/cortex-snowflake-e2e.test.ts | 73 ++++ .../opencode/test/provider/snowflake.test.ts | 363 +++++++++++++++++- 5 files changed, 580 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52e10dc9..146d6b48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Snowflake Cortex prompt caching now activates for Claude models.** Cortex only honors caching markers placed inside content blocks (`messages[].content[].cache_control`), but requests carried them as message-level fields — so every request billed the full input rate (`cache_read_input`/`cache_write_input` stayed NULL in `TOKENS_GRANULAR`). The provider now relocates the markers into content blocks (system prompt + trailing messages, max 4 breakpoints), cutting repeated-prefix input cost by up to 90% on long agent sessions. If a Cortex account rejects the marked shape, the request is retried once without markers and caching is disabled for the session. (#1009) + ## [0.9.1] - 2026-07-08 This release rebases altimate-code onto **upstream OpenCode v1.17.9** (bridged up from v1.4.0 — ~165 upstream commits) behind the fork's own fixes and hardening. It is a larger-than-usual jump from 0.8.10; the upgrade is automatic and in-place (see **Upgrading from 0.8.10** below). diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 0689a6a0b..f687500cf 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -276,6 +276,8 @@ Create a PAT in Snowsight: **Admin > Security > Programmatic Access Tokens**. Billing flows through your Snowflake credits — no per-token costs. +Prompt caching is applied automatically for Claude models: cache markers are placed on the system prompt and trailing messages, so repeated context in long sessions is billed at Snowflake's cached-input rate (a 90% discount on cache reads, 5-minute TTL). Cache activity appears as `cache_read_input`/`cache_write_input` in Snowflake's `CORTEX_FUNCTIONS_QUERY_USAGE_HISTORY`/`TOKENS_GRANULAR` telemetry. OpenAI models are cached automatically by Cortex itself; other model families don't support caching. + **Available models:** | Model | Tool Calling | diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index 8528a0c32..1bbad5393 100644 --- a/packages/opencode/src/altimate/plugin/snowflake.ts +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -40,6 +40,104 @@ export function buildToolCapableSet( /** Snowflake account identifiers contain only alphanumeric, hyphen, underscore, and dot characters. */ export const VALID_ACCOUNT_RE = /^[a-zA-Z0-9._-]+$/ +/** Snowflake Cortex accepts at most 4 cache breakpoints per request (Anthropic limit). */ +const CACHE_BREAKPOINT_LIMIT = 4 + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** + * Relocate prompt-cache markers into content blocks for Claude models. + * + * Cortex only honors caching via `messages[].content[].cache_control` + * (ephemeral, max 4 breakpoints). The OpenAI-compatible AI SDK serializes our + * cache providerOptions as message-level fields — on system messages, on + * single-text-part user messages (collapsed to string content), on tool + * messages, and into assistant `tool_calls` entries — all of which Cortex + * silently ignores, so every request bills the full input rate. + * + * Returns true when the request carries block-level markers after relocation. + */ +export function relocateCacheControl(parsed: Record): boolean { + if (typeof parsed.model !== "string" || !parsed.model.includes("claude")) return false + if (!Array.isArray(parsed.messages)) return false + + for (const msg of parsed.messages) { + if (!isRecord(msg)) continue + + // The SDK spreads part metadata into tool_calls entries — invalid location. + if (Array.isArray(msg.tool_calls)) { + for (const call of msg.tool_calls) { + if (isRecord(call)) delete call.cache_control + } + } + + const marker = isRecord(msg.cache_control) ? msg.cache_control : undefined + delete msg.cache_control + if (!marker) continue + + if (typeof msg.content === "string") { + if (msg.content.length === 0) continue + msg.content = [{ type: "text", text: msg.content, cache_control: marker }] + continue + } + + if (!Array.isArray(msg.content)) continue + + // Attach to the last block; skip empty text blocks (markers there are rejected). + for (let i = msg.content.length - 1; i >= 0; i--) { + const block = msg.content[i] + if (!isRecord(block)) continue + if (block.type === "text" && (typeof block.text !== "string" || block.text.length === 0)) continue + if (!isRecord(block.cache_control)) block.cache_control = marker + break + } + } + + // Enforce the breakpoint limit, keeping the last markers (longest prefixes). + const marked: Record[] = [] + for (const msg of parsed.messages) { + if (!isRecord(msg) || !Array.isArray(msg.content)) continue + for (const block of msg.content) { + if (isRecord(block) && block.cache_control) marked.push(block) + } + } + for (const block of marked.slice(0, Math.max(0, marked.length - CACHE_BREAKPOINT_LIMIT))) { + delete block.cache_control + } + + return marked.length > 0 +} + +function stripParsedCacheControl(parsed: Record) { + if (!Array.isArray(parsed.messages)) return + for (const msg of parsed.messages) { + if (!isRecord(msg)) continue + delete msg.cache_control + if (Array.isArray(msg.tool_calls)) { + for (const call of msg.tool_calls) { + if (isRecord(call)) delete call.cache_control + } + } + if (!Array.isArray(msg.content)) continue + for (const block of msg.content) { + if (isRecord(block)) delete block.cache_control + } + } +} + +/** Remove every cache marker from a request body (fallback when Cortex rejects them). */ +export function stripCacheControl(bodyText: string): string { + try { + const parsed = JSON.parse(bodyText) + stripParsedCacheControl(parsed) + return JSON.stringify(parsed) + } catch { + return bodyText + } +} + /** Parse a `account::token` PAT credential string. */ export function parseSnowflakePAT(code: string): { account: string; token: string } | null { const sep = code.indexOf("::") @@ -58,11 +156,15 @@ export function parseSnowflakePAT(code: string): { account: string; token: strin * @param toolCapable Model IDs that should retain `tools` / `tool_choice` / tool messages. * Build via `buildToolCapableSet(provider.models)` at loader time so * user-added models with `tool_call: true` in `altimate-code.json` are honored. + * @param cacheControl When false, strip every cache marker instead of relocating + * them into content blocks (set after Cortex rejected a + * cache-marked request). */ export function transformSnowflakeBody( bodyText: string, toolCapable: ReadonlySet, -): { body: string; syntheticStop?: Response } { + cacheControl = true, +): { body: string; syntheticStop?: Response; cacheApplied?: boolean } { const parsed = JSON.parse(bodyText) // Snowflake uses max_completion_tokens instead of max_tokens @@ -71,6 +173,10 @@ export function transformSnowflakeBody( delete parsed.max_tokens } + let cacheApplied = false + if (cacheControl) cacheApplied = relocateCacheControl(parsed) + else stripParsedCacheControl(parsed) + // Strip tools for models that don't support tool calling on Snowflake Cortex. // Also remove orphaned tool_calls from messages to avoid Snowflake API errors. if (!toolCapable.has(parsed.model)) { @@ -105,6 +211,7 @@ export function transformSnowflakeBody( }) return { body: JSON.stringify(parsed), + cacheApplied, syntheticStop: new Response(stream, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, @@ -113,7 +220,7 @@ export function transformSnowflakeBody( } } - return { body: JSON.stringify(parsed) } + return { body: JSON.stringify(parsed), cacheApplied } } export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise { @@ -137,6 +244,11 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise {}) + return retry + } + void retry.body?.cancel().catch(() => {}) + return response + } + } + + return response }, } }, diff --git a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts index 3603a3478..ad0613b03 100644 --- a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts +++ b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts @@ -371,6 +371,79 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { }, 15000) }) + // ------------------------------------------------------------------------- + // Prompt caching (issue #1009) + // ------------------------------------------------------------------------- + describe("Prompt Caching", () => { + // ~1,400 tokens of deterministic filler — above the 1,024-token minimum + // for claude-3-5-sonnet cache entries (Opus/Haiku models need 4,096). + const filler = Array.from({ length: 700 }, (_, i) => `fact-${i}: the answer to sub-question ${i} is ${i * 7}.`).join(" ") + + test("block-level cache_control on system message produces cache activity", async () => { + const request = () => + fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + model: "claude-3-5-sonnet", + messages: [ + { + role: "system", + content: [{ type: "text", text: `You are terse. Reference data: ${filler}`, cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: "What is fact-3? One word." }, + ], + max_completion_tokens: 32, + stream: false, + }), + }) + + const first = await request() + expect(first.status).toBe(200) + const second = await request() + expect(second.status).toBe(200) + const usage = ((await second.json()) as any).usage + // For Claude, `cached_tokens` covers cache reads AND writes combined, so + // this proves the markers activate caching but not prefix reuse per se — + // verify reads specifically via `cache_read_input` in TOKENS_GRANULAR. + expect(usage?.prompt_tokens_details?.cached_tokens ?? 0).toBeGreaterThan(0) + }, 60000) + + test("tool message with array content and cache_control is accepted", async () => { + const resp = await fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + model: "claude-3-5-sonnet", + messages: [ + { role: "user", content: "Run the lookup tool for fact-3." }, + { + role: "assistant", + content: "", + tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }], + }, + { + role: "tool", + tool_call_id: "call_1", + content: [{ type: "text", text: `lookup result: ${filler}`, cache_control: { type: "ephemeral" } }], + }, + ], + tools: [ + { + type: "function", + function: { name: "lookup", description: "look up facts", parameters: { type: "object", properties: {} } }, + }, + ], + max_completion_tokens: 32, + stream: false, + }), + }) + // This is the exact shape the plugin emits after relocating markers on + // tool results — the request must not be rejected. + expect(resp.status).toBe(200) + }, 30000) + }) + // ------------------------------------------------------------------------- // Request transforms (unit-level, no network) // ------------------------------------------------------------------------- diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index 62a89342e..f7e41e5e3 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -1,12 +1,22 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { generateText, type ModelMessage } from "ai" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { ProviderTransform } from "../../src/provider/transform" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ProjectID } from "../../src/project/schema" import { Provider } from "../../src/provider/provider" import { Auth } from "../../src/auth" import { Env } from "../../src/env" -import { buildToolCapableSet, parseSnowflakePAT, transformSnowflakeBody } from "../../src/altimate/plugin/snowflake" +import { + buildToolCapableSet, + parseSnowflakePAT, + relocateCacheControl, + SnowflakeCortexAuthPlugin, + stripCacheControl, + transformSnowflakeBody, +} from "../../src/altimate/plugin/snowflake" function provideProviderTestInstance(input: { directory: string @@ -384,6 +394,275 @@ describe("transformSnowflakeBody", () => { }) }) +// --------------------------------------------------------------------------- +// Prompt-cache marker relocation (issue #1009) +// --------------------------------------------------------------------------- + +describe("relocateCacheControl", () => { + const EPHEMERAL = { type: "ephemeral" } + + test("moves message-level marker on system message into a text block", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [{ role: "system", content: "You are helpful.", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0]).toEqual({ + role: "system", + content: [{ type: "text", text: "You are helpful.", cache_control: EPHEMERAL }], + }) + }) + + test("converts single-string user message with marker into a text block", () => { + const parsed: Record = { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hello", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0].content).toEqual([{ type: "text", text: "hello", cache_control: EPHEMERAL }]) + expect("cache_control" in parsed.messages[0]).toBe(false) + }) + + test("converts tool message string content with marker into a text block", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [{ role: "tool", tool_call_id: "call_1", content: "tool output", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0].content).toEqual([{ type: "text", text: "tool output", cache_control: EPHEMERAL }]) + expect(parsed.messages[0].tool_call_id).toBe("call_1") + }) + + test("strips stray markers from assistant tool_calls entries", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [ + { + role: "assistant", + content: "", + tool_calls: [ + { id: "call_1", type: "function", function: { name: "f", arguments: "{}" }, cache_control: EPHEMERAL }, + ], + }, + ], + } + expect(relocateCacheControl(parsed)).toBe(false) + expect("cache_control" in parsed.messages[0].tool_calls[0]).toBe(false) + }) + + test("keeps existing block-level marker on multi-part user message", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second", cache_control: EPHEMERAL }, + ], + }, + ], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0].content[1].cache_control).toEqual(EPHEMERAL) + }) + + test("leaves pre-existing block-level markers in place, including non-text blocks", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" }, cache_control: EPHEMERAL }, + ], + }, + ], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0].content[1].cache_control).toEqual(EPHEMERAL) + expect("cache_control" in parsed.messages[0].content[0]).toBe(false) + }) + + test("message-level marker attaches to the last block, skipping empty text blocks", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "context" }, + { type: "text", text: "" }, + ], + cache_control: EPHEMERAL, + }, + ], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0].content[0].cache_control).toEqual(EPHEMERAL) + expect("cache_control" in parsed.messages[0].content[1]).toBe(false) + }) + + test("caps at 4 breakpoints, keeping the last ones", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [1, 2, 3, 4, 5].map((n) => ({ + role: "user", + content: `message ${n}`, + cache_control: EPHEMERAL, + })), + } + expect(relocateCacheControl(parsed)).toBe(true) + const marked = parsed.messages.map((m: any) => Boolean(m.content[0].cache_control)) + expect(marked).toEqual([false, true, true, true, true]) + }) + + test("drops marker when message content is an empty string", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [{ role: "assistant", content: "", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(false) + expect(parsed.messages[0].content).toBe("") + expect("cache_control" in parsed.messages[0]).toBe(false) + }) + + test("leaves non-claude models untouched", () => { + const parsed: Record = { + model: "openai-gpt-5", + messages: [{ role: "system", content: "sys", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(false) + expect(parsed.messages[0].content).toBe("sys") + expect(parsed.messages[0].cache_control).toEqual(EPHEMERAL) + }) + + test("transformSnowflakeBody surfaces cacheApplied and relocates markers", () => { + const input = JSON.stringify({ + model: "claude-opus-4-7", + max_tokens: 1000, + messages: [ + { role: "system", content: "sys prompt", cache_control: EPHEMERAL }, + { role: "user", content: "hi" }, + ], + }) + const result = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) + expect(result.cacheApplied).toBe(true) + const parsed = JSON.parse(result.body) + expect(parsed.max_completion_tokens).toBe(1000) + expect(parsed.messages[0].content).toEqual([{ type: "text", text: "sys prompt", cache_control: EPHEMERAL }]) + }) + + test("transformSnowflakeBody strips all markers when cacheControl is false", () => { + const input = JSON.stringify({ + model: "claude-opus-4-7", + messages: [ + { role: "system", content: "sys prompt", cache_control: EPHEMERAL }, + { role: "user", content: [{ type: "text", text: "hi", cache_control: EPHEMERAL }] }, + ], + }) + const result = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE, false) + expect(result.cacheApplied).toBe(false) + const parsed = JSON.parse(result.body) + expect(parsed.messages[0].content).toBe("sys prompt") + expect("cache_control" in parsed.messages[0]).toBe(false) + expect("cache_control" in parsed.messages[1].content[0]).toBe(false) + }) + + test("applyCaching markers survive real SDK serialization and relocate into blocks", async () => { + // Full-chain contract: ProviderTransform.applyCaching → actual + // @ai-sdk/openai-compatible request serialization → transformSnowflakeBody. + // Guards against SDK upgrades changing where cache providerOptions land. + const cortexModel = { + id: "claude-opus-4-7", + providerID: "snowflake-cortex", + api: { id: "claude-opus-4-7", url: "", npm: "@ai-sdk/openai-compatible" }, + name: "Claude Opus 4.7", + 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: 1000000, output: 128000 }, + status: "active", + options: {}, + headers: {}, + } as any + + let msgs: ModelMessage[] = [ + { role: "system", content: "You are a data engineer." }, + { role: "user", content: "optimize my query" }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "call_1", toolName: "run_sql", input: {} }] }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call_1", toolName: "run_sql", output: { type: "text", value: "42 rows" } }], + }, + ] + msgs = ProviderTransform.message(msgs, cortexModel, {}) + + const captured: string[] = [] + const sdk = createOpenAICompatible({ + name: "snowflake-cortex", + baseURL: "https://cortex.test/api/v2/cortex/v1", + apiKey: "pat", + fetch: (async (_url: any, init: any) => { + captured.push(String(init?.body)) + return new Response( + JSON.stringify({ + id: "c1", + object: "chat.completion", + created: 0, + model: "claude-opus-4-7", + choices: [{ index: 0, message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as any, + }) + + await generateText({ model: sdk("claude-opus-4-7"), messages: msgs, maxRetries: 0 }) + + expect(captured).toHaveLength(1) + const raw = JSON.parse(captured[0]) + // Contract: the SDK serializes cache providerOptions as message-level fields + expect(raw.messages.find((m: any) => m.role === "system").cache_control).toEqual(EPHEMERAL) + expect(raw.messages.find((m: any) => m.role === "tool").cache_control).toEqual(EPHEMERAL) + + // The plugin transform relocates them into content blocks for Cortex + const result = transformSnowflakeBody(captured[0], TOOLCAPABLE_FIXTURE) + expect(result.cacheApplied).toBe(true) + const fixed = JSON.parse(result.body) + const sys = fixed.messages.find((m: any) => m.role === "system") + expect(sys.content).toEqual([{ type: "text", text: "You are a data engineer.", cache_control: EPHEMERAL }]) + expect("cache_control" in sys).toBe(false) + const tool = fixed.messages.find((m: any) => m.role === "tool") + expect(tool.content).toEqual([{ type: "text", text: "42 rows", cache_control: EPHEMERAL }]) + for (const call of fixed.messages.find((m: any) => m.role === "assistant").tool_calls ?? []) { + expect("cache_control" in call).toBe(false) + } + }) + + test("stripCacheControl removes every marker from the body", () => { + const body = JSON.stringify({ + model: "claude-opus-4-7", + messages: [ + { role: "system", content: [{ type: "text", text: "sys", cache_control: EPHEMERAL }] }, + { role: "user", content: "hi", cache_control: EPHEMERAL }, + ], + }) + const parsed = JSON.parse(stripCacheControl(body)) + expect("cache_control" in parsed.messages[0].content[0]).toBe(false) + expect("cache_control" in parsed.messages[1]).toBe(false) + }) +}) + // --------------------------------------------------------------------------- // Fetch interceptor (SnowflakeCortexAuthPlugin) // --------------------------------------------------------------------------- @@ -433,6 +712,88 @@ describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { const lines = text.split("\n").filter((l: string) => l.startsWith("data: ")) expect(lines.length).toBe(3) // delta, stop, [DONE] }) + + const makeLoaderFetch = async () => { + const hooks = await SnowflakeCortexAuthPlugin({} as any) + const options = await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + access: "test-token", + refresh: "", + expires: Date.now() + 3600_000, + accountId: "myorg-myaccount", + }) as any, + { models: { "claude-opus-4-7": { capabilities: { toolcall: true } } } } as any, + ) + return options.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise + } + + const cacheMarkedBody = () => + JSON.stringify({ + model: "claude-opus-4-7", + messages: [ + { role: "system", content: "sys prompt", cache_control: { type: "ephemeral" } }, + { role: "user", content: "hi" }, + ], + }) + + test("retries once without cache markers on 400 and disables caching for the session", async () => { + const pluginFetch = await makeLoaderFetch() + const bodies: string[] = [] + const originalFetch = globalThis.fetch + const hasBlockMarkers = (body: string) => + JSON.parse(body).messages.some( + (m: any) => Array.isArray(m.content) && m.content.some((b: any) => b?.cache_control), + ) + globalThis.fetch = (async (_input: any, init?: RequestInit) => { + const body = String(init?.body) + bodies.push(body) + if (hasBlockMarkers(body)) return new Response("{}", { status: 400 }) + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }) + }) as typeof fetch + + try { + const url = "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1/chat/completions" + const first = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(first.status).toBe(200) + expect(bodies).toHaveLength(2) + expect(hasBlockMarkers(bodies[0])).toBe(true) + expect(hasBlockMarkers(bodies[1])).toBe(false) + + // Sticky disable: the next request carries no markers at all + const second = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(second.status).toBe(200) + expect(bodies).toHaveLength(3) + expect(bodies[2]).not.toContain("cache_control") + } finally { + globalThis.fetch = originalFetch + } + }) + + test("returns the original 400 and keeps caching enabled when the stripped retry also fails", async () => { + const pluginFetch = await makeLoaderFetch() + const bodies: string[] = [] + const originalFetch = globalThis.fetch + globalThis.fetch = (async (_input: any, init?: RequestInit) => { + bodies.push(String(init?.body)) + return new Response(JSON.stringify({ message: "bad request" }), { status: 400 }) + }) as typeof fetch + + try { + const url = "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1/chat/completions" + const first = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(first.status).toBe(400) + expect(bodies).toHaveLength(2) + + // Caching stays enabled — the next request still carries block-level markers + await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(bodies[2]).toContain("cache_control") + expect(JSON.parse(bodies[2]).messages[0].content[0].cache_control).toEqual({ type: "ephemeral" }) + } finally { + globalThis.fetch = originalFetch + } + }) }) // --------------------------------------------------------------------------- From 04b0faf9f2fb48c201dad74b00cb6d6a7edfdc7f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 12:23:52 -0700 Subject: [PATCH 2/7] fix: [#1009] walk tool-message cache markers back to cacheable messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification against a real Cortex account (claude-sonnet-4-5) showed Cortex honors block-level `cache_control` on system/user/assistant messages (cache activity confirmed via `usage.prompt_tokens_details.cached_tokens`), but **silently ignores** markers on `role:"tool"` messages (accepted, zero cache). Relocation now walks a tool-message marker back to the nearest earlier cacheable message — in agent loops the trailing breakpoint lands on the last assistant/user message, which caches the full conversation prefix except the newest tool result. Markers already sitting on tool-message blocks are stripped so they don't burn the 4-breakpoint budget. Also verified live: assistant messages with text blocks + `tool_calls` + marker cache correctly (the exact mid-loop shape the plugin now emits), and array content on `role:"tool"` never 400s (the fallback retry stays as insurance only). E2E suite updates: swap `claude-3-5-sonnet` (now "unknown model" on current Cortex accounts) for `claude-sonnet-4-5` in live-network tests, and add an agent-loop caching test. Full live run: 40 pass / 0 fail via key-pair JWT. Co-Authored-By: Claude Fable 5 --- .../opencode/src/altimate/plugin/snowflake.ts | 75 +++++++++++++------ .../altimate/cortex-snowflake-e2e.test.ts | 75 +++++++++++++++---- .../opencode/test/provider/snowflake.test.ts | 59 +++++++++++++-- 3 files changed, 165 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index 1bbad5393..50b8c3143 100644 --- a/packages/opencode/src/altimate/plugin/snowflake.ts +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -47,23 +47,57 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } +/** Roles whose content-block cache markers Cortex honors (verified live 2026-07-20). */ +const CACHEABLE_ROLES = new Set(["system", "user", "assistant"]) + +/** + * Attach a cache marker to the last non-empty content block of a message. + * String content is wrapped into a text block. Returns false when the message + * has no block that can carry a marker (e.g. empty content). + */ +function attachMarker(msg: Record, marker: Record): boolean { + if (typeof msg.content === "string") { + if (msg.content.length === 0) return false + msg.content = [{ type: "text", text: msg.content, cache_control: marker }] + return true + } + if (!Array.isArray(msg.content)) return false + for (let i = msg.content.length - 1; i >= 0; i--) { + const block = msg.content[i] + if (!isRecord(block)) continue + if (block.type === "text" && (typeof block.text !== "string" || block.text.length === 0)) continue + if (!isRecord(block.cache_control)) block.cache_control = marker + return true + } + return false +} + /** * Relocate prompt-cache markers into content blocks for Claude models. * * Cortex only honors caching via `messages[].content[].cache_control` - * (ephemeral, max 4 breakpoints). The OpenAI-compatible AI SDK serializes our - * cache providerOptions as message-level fields — on system messages, on - * single-text-part user messages (collapsed to string content), on tool - * messages, and into assistant `tool_calls` entries — all of which Cortex - * silently ignores, so every request bills the full input rate. + * (ephemeral, max 4 breakpoints) — and only on system/user/assistant messages; + * markers on `role:"tool"` messages are accepted but silently ignored + * (verified against a live Cortex account). The OpenAI-compatible AI SDK + * serializes our cache providerOptions as message-level fields — on system + * messages, on single-text-part user messages (collapsed to string content), + * on tool messages, and into assistant `tool_calls` entries — all of which + * Cortex ignores, so every request bills the full input rate. + * + * Message-level markers move into that message's content blocks; a marker on + * a tool message (or a message with no attachable block) walks back to the + * nearest earlier cacheable message, keeping the breakpoint as close to the + * end of the conversation as Cortex allows. * * Returns true when the request carries block-level markers after relocation. */ export function relocateCacheControl(parsed: Record): boolean { if (typeof parsed.model !== "string" || !parsed.model.includes("claude")) return false if (!Array.isArray(parsed.messages)) return false + const messages = parsed.messages - for (const msg of parsed.messages) { + for (let idx = 0; idx < messages.length; idx++) { + const msg = messages[idx] if (!isRecord(msg)) continue // The SDK spreads part metadata into tool_calls entries — invalid location. @@ -77,30 +111,25 @@ export function relocateCacheControl(parsed: Record): boolean { delete msg.cache_control if (!marker) continue - if (typeof msg.content === "string") { - if (msg.content.length === 0) continue - msg.content = [{ type: "text", text: msg.content, cache_control: marker }] - continue - } - - if (!Array.isArray(msg.content)) continue - - // Attach to the last block; skip empty text blocks (markers there are rejected). - for (let i = msg.content.length - 1; i >= 0; i--) { - const block = msg.content[i] - if (!isRecord(block)) continue - if (block.type === "text" && (typeof block.text !== "string" || block.text.length === 0)) continue - if (!isRecord(block.cache_control)) block.cache_control = marker - break + for (let t = idx; t >= 0; t--) { + const target = messages[t] + if (!isRecord(target) || !CACHEABLE_ROLES.has(target.role)) continue + if (attachMarker(target, marker)) break } } // Enforce the breakpoint limit, keeping the last markers (longest prefixes). + // Markers on non-cacheable roles are dead weight against the limit — drop them. const marked: Record[] = [] - for (const msg of parsed.messages) { + for (const msg of messages) { if (!isRecord(msg) || !Array.isArray(msg.content)) continue for (const block of msg.content) { - if (isRecord(block) && block.cache_control) marked.push(block) + if (!isRecord(block) || !block.cache_control) continue + if (!CACHEABLE_ROLES.has(msg.role)) { + delete block.cache_control + continue + } + marked.push(block) } } for (const block of marked.slice(0, Math.max(0, marked.length - CACHE_BREAKPOINT_LIMIT))) { diff --git a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts index ad0613b03..9aa7db325 100644 --- a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts +++ b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts @@ -133,7 +133,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { describe("Authentication", () => { test("valid credentials succeed", async () => { const resp = await cortexChat({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "Reply with exactly: hello" }], stream: false, max_tokens: 32, @@ -154,7 +154,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { "X-Snowflake-Authorization-Token-Type": "PROGRAMMATIC_ACCESS_TOKEN", }, body: JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "test" }], max_completion_tokens: 16, stream: false, @@ -170,7 +170,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { describe("Non-Streaming Completions", () => { test("returns valid JSON completion", async () => { const resp = await cortexChat({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "What is 2+2? Reply with just the number." }], stream: false, max_tokens: 16, @@ -189,7 +189,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { ...authHeaders(), }, body: JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "Say hello" }], max_completion_tokens: 16, stream: false, @@ -200,7 +200,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { test("reports token usage", async () => { const resp = await cortexChat({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "Say hi" }], stream: false, max_tokens: 16, @@ -218,7 +218,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { describe("Streaming Completions", () => { test("returns SSE stream with chunked deltas", async () => { const resp = await cortexChat({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "Count from 1 to 3." }], stream: true, max_tokens: 64, @@ -278,7 +278,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { // Tool calling — only Claude models support it on Cortex // ------------------------------------------------------------------------- describe("Tool Calling", () => { - const claudeModel = "claude-3-5-sonnet" + const claudeModel = "claude-sonnet-4-5" const nonClaudeModel = "mistral-large2" test(`${claudeModel} supports tool calls`, async () => { @@ -354,7 +354,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { ...authHeaders(), }, body: JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [ { role: "user", content: "hello" }, { role: "assistant", content: "I'm here" }, @@ -376,7 +376,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { // ------------------------------------------------------------------------- describe("Prompt Caching", () => { // ~1,400 tokens of deterministic filler — above the 1,024-token minimum - // for claude-3-5-sonnet cache entries (Opus/Haiku models need 4,096). + // for claude-sonnet-4-5 cache entries (Opus/Haiku models need 4,096). const filler = Array.from({ length: 700 }, (_, i) => `fact-${i}: the answer to sub-question ${i} is ${i * 7}.`).join(" ") test("block-level cache_control on system message produces cache activity", async () => { @@ -385,7 +385,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [ { role: "system", @@ -414,7 +414,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [ { role: "user", content: "Run the lookup tool for fact-3." }, { @@ -438,10 +438,53 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { stream: false, }), }) - // This is the exact shape the plugin emits after relocating markers on - // tool results — the request must not be rejected. + // Cortex accepts array content on role:"tool" but IGNORES its cache + // markers (verified live: cached_tokens stays 0) — which is why the + // plugin walks tool-message markers back to the nearest cacheable + // message instead. This test guards the acceptance half: the shape + // must never 400. expect(resp.status).toBe(200) }, 30000) + + test("agent-loop shape: marker on assistant text block with tool_calls caches", async () => { + // The exact shape the plugin emits mid-agent-loop: system breakpoint plus + // a trailing breakpoint on the assistant message (walked back from the + // tool result), while the tool result stays a plain string. + const request = () => + fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + model: "claude-sonnet-4-5", + messages: [ + { + role: "system", + content: [{ type: "text", text: "You are terse.", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: "Run the lookup tool, then answer." }, + { + role: "assistant", + content: [{ type: "text", text: `Looking it up. Notes: ${filler}`, cache_control: { type: "ephemeral" } }], + tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }], + }, + { role: "tool", tool_call_id: "call_1", content: "lookup result: 21" }, + ], + tools: [ + { + type: "function", + function: { name: "lookup", description: "look up facts", parameters: { type: "object", properties: {} } }, + }, + ], + max_completion_tokens: 32, + stream: false, + }), + }) + + const first = await request() + expect(first.status).toBe(200) + const usage = ((await first.json()) as any).usage + expect(usage?.prompt_tokens_details?.cached_tokens ?? 0).toBeGreaterThan(0) + }, 30000) }) // ------------------------------------------------------------------------- @@ -450,7 +493,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { describe("Request Transforms", () => { test("max_tokens renamed to max_completion_tokens", () => { const input = JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", messages: [{ role: "user", content: "test" }], max_tokens: 100, stream: true, @@ -476,7 +519,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { test("synthetic stop skipped for non-streaming", () => { const input = JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", stream: false, messages: [ { role: "user", content: "test" }, @@ -489,7 +532,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { test("synthetic stop triggered for streaming with trailing assistant", () => { const input = JSON.stringify({ - model: "claude-3-5-sonnet", + model: "claude-sonnet-4-5", stream: true, messages: [ { role: "user", content: "test" }, diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index f7e41e5e3..fd291d619 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -423,14 +423,59 @@ describe("relocateCacheControl", () => { expect("cache_control" in parsed.messages[0]).toBe(false) }) - test("converts tool message string content with marker into a text block", () => { + test("tool message marker walks back to the nearest cacheable message", () => { + // Cortex accepts but silently ignores markers on role:"tool" — so the + // breakpoint moves to the closest earlier system/user/assistant message. const parsed: Record = { model: "claude-opus-4-7", - messages: [{ role: "tool", tool_call_id: "call_1", content: "tool output", cache_control: EPHEMERAL }], + messages: [ + { role: "user", content: "run the tool" }, + { role: "assistant", content: "On it.", tool_calls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_1", content: "tool output", cache_control: EPHEMERAL }, + ], } expect(relocateCacheControl(parsed)).toBe(true) - expect(parsed.messages[0].content).toEqual([{ type: "text", text: "tool output", cache_control: EPHEMERAL }]) - expect(parsed.messages[0].tool_call_id).toBe("call_1") + // Tool message keeps plain string content, no marker + expect(parsed.messages[2].content).toBe("tool output") + expect("cache_control" in parsed.messages[2]).toBe(false) + // Marker lands on the assistant message's text, now as a block + expect(parsed.messages[1].content).toEqual([{ type: "text", text: "On it.", cache_control: EPHEMERAL }]) + }) + + test("tool marker skips an empty assistant message and lands on the user message", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [ + { role: "user", content: "run the tool" }, + { role: "assistant", content: "", tool_calls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_1", content: "tool output", cache_control: EPHEMERAL }, + ], + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[1].content).toBe("") + expect(parsed.messages[0].content).toEqual([{ type: "text", text: "run the tool", cache_control: EPHEMERAL }]) + }) + + test("tool marker with no preceding cacheable message is dropped", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [{ role: "tool", tool_call_id: "call_1", content: "tool output", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(false) + expect(parsed.messages[0].content).toBe("tool output") + expect("cache_control" in parsed.messages[0]).toBe(false) + }) + + test("pre-existing block-level markers on tool messages are stripped", () => { + const parsed: Record = { + model: "claude-opus-4-7", + messages: [ + { role: "user", content: "hi" }, + { role: "tool", tool_call_id: "call_1", content: [{ type: "text", text: "out", cache_control: EPHEMERAL }] }, + ], + } + expect(relocateCacheControl(parsed)).toBe(false) + expect("cache_control" in parsed.messages[1].content[0]).toBe(false) }) test("strips stray markers from assistant tool_calls entries", () => { @@ -642,8 +687,12 @@ describe("relocateCacheControl", () => { const sys = fixed.messages.find((m: any) => m.role === "system") expect(sys.content).toEqual([{ type: "text", text: "You are a data engineer.", cache_control: EPHEMERAL }]) expect("cache_control" in sys).toBe(false) + // Tool markers walk back past the empty assistant message to the user message const tool = fixed.messages.find((m: any) => m.role === "tool") - expect(tool.content).toEqual([{ type: "text", text: "42 rows", cache_control: EPHEMERAL }]) + expect(tool.content).toBe("42 rows") + expect("cache_control" in tool).toBe(false) + const usr = fixed.messages.find((m: any) => m.role === "user") + expect(usr.content).toEqual([{ type: "text", text: "optimize my query", cache_control: EPHEMERAL }]) for (const call of fixed.messages.find((m: any) => m.role === "assistant").tool_calls ?? []) { expect("cache_control" in call).toBe(false) } From 39597d74de8a1da8fa37a151eda45fad4e479675 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 12:45:28 -0700 Subject: [PATCH 3/7] feat: [#1009] refresh Snowflake Cortex model catalog; verify caching per family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog probed live against a Cortex account (2026-07-20, every ID via /chat/completions): Added: - `claude-sonnet-5` (1M ctx / 64K out) — tool calling and prompt caching verified live (cache markers honored, 8,965 cached tokens) - `claude-opus-4-8` (1M / 128K, public preview) — caching verified (10,369 cached; agent-loop shape 20,385) - `openai-gpt-5.4` (gpt-5 family defaults), `openai-gpt-5.4-mini` and `openai-gpt-5.4-nano` (400K / 128K per Snowflake's restrictions table) Removed (requests hard-fail on Cortex now): - Deprecated by Snowflake on 2026-07-08: `deepseek-r1`, `mistral-large`, `llama3.1-405b`, `snowflake-llama-3.3-70b` - Delisted ("unknown model"): `claude-3-7-sonnet`, `claude-3-5-sonnet`, `openai-gpt-5-chat`, `llama4-scout`, `snowflake-llama-3.1-405b`, `mixtral-8x7b`, `gemini-3.1-pro` Caching verified per family on the live account: - Claude (all 9 picker models): explicit block markers cache on every model - OpenAI: automatic caching confirmed on gpt-4.1 / gpt-5 / gpt-5.4 / gpt-5.4-mini (second request shows 11-13K cached tokens); gpt-5.2 showed no cache activity in this account/region - Llama/Mistral: markers tolerated (no 400), no caching, as documented `claude-fable-5` is recognized by Cortex (maps to claude-fable-5-global) but unavailable on tested accounts — noted in the map for later. Catalog tests updated to lock in the verified list, including assertions that removed models are gone from the picker. Live e2e: 37 pass / 0 fail. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++ docs/docs/configure/providers.md | 15 +-- packages/opencode/src/provider/provider.ts | 102 ++++++------------ .../altimate/cortex-snowflake-e2e.test.ts | 14 ++- .../opencode/test/provider/snowflake.test.ts | 48 +++++---- 5 files changed, 80 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 146d6b48a..b3519b11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Snowflake Cortex: `claude-sonnet-5`, `claude-opus-4-8`, and OpenAI GPT-5.4 (`openai-gpt-5.4`, `-mini`, `-nano`) in the model picker.** All verified live against Cortex, including prompt caching and tool calling on the new Claude models. + +### Changed + +- **Snowflake Cortex model catalog refreshed against the live service (2026-07-20).** Removed models Snowflake has deprecated (July 8, 2026: `deepseek-r1`, `mistral-large`, `llama3.1-405b`, `snowflake-llama-3.3-70b`) or delisted (`claude-3-7-sonnet`, `claude-3-5-sonnet`, `openai-gpt-5-chat`, `llama4-scout`, `mixtral-8x7b`, `snowflake-llama-3.1-405b`, `gemini-3.1-pro`) — requests to these now hard-fail on Cortex. Locally registered models via `altimate-code.json` are unaffected. + ### Fixed - **Snowflake Cortex prompt caching now activates for Claude models.** Cortex only honors caching markers placed inside content blocks (`messages[].content[].cache_control`), but requests carried them as message-level fields — so every request billed the full input rate (`cache_read_input`/`cache_write_input` stayed NULL in `TOKENS_GRANULAR`). The provider now relocates the markers into content blocks (system prompt + trailing messages, max 4 breakpoints), cutting repeated-prefix input cost by up to 90% on long agent sessions. If a Cortex account rejects the marked shape, the request is retried once without markers and caching is disabled for the session. (#1009) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index f687500cf..0ad0dc00d 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -278,15 +278,16 @@ Billing flows through your Snowflake credits — no per-token costs. Prompt caching is applied automatically for Claude models: cache markers are placed on the system prompt and trailing messages, so repeated context in long sessions is billed at Snowflake's cached-input rate (a 90% discount on cache reads, 5-minute TTL). Cache activity appears as `cache_read_input`/`cache_write_input` in Snowflake's `CORTEX_FUNCTIONS_QUERY_USAGE_HISTORY`/`TOKENS_GRANULAR` telemetry. OpenAI models are cached automatically by Cortex itself; other model families don't support caching. -**Available models:** +**Available models** (catalog verified live against Cortex on 2026-07-20): | Model | Tool Calling | |-------|-------------| -| `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-opus-4-5`, `claude-haiku-4-5`, `claude-4-sonnet`, `claude-3-7-sonnet`, `claude-3-5-sonnet` | Yes | -| `openai-gpt-4.1`, `openai-gpt-5`, `openai-gpt-5.1`, `openai-gpt-5.2`, `openai-gpt-5-mini`, `openai-gpt-5-nano`, `openai-gpt-5-chat` | Yes | -| `llama4-maverick`, `llama4-scout`, `llama3.3-70b`, `snowflake-llama-3.3-70b`, `snowflake-llama-3.1-405b`, `llama3.1-70b`, `llama3.1-405b`, `llama3.1-8b` | No | -| `mistral-large`, `mistral-large2`, `mistral-7b`, `mixtral-8x7b` | No | -| `deepseek-r1`, `gemini-3.1-pro` | No | +| `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-opus-4-5`, `claude-haiku-4-5`, `claude-4-sonnet` | Yes | +| `openai-gpt-4.1`, `openai-gpt-5`, `openai-gpt-5.1`, `openai-gpt-5.2`, `openai-gpt-5.4`, `openai-gpt-5.4-mini`, `openai-gpt-5.4-nano`, `openai-gpt-5-mini`, `openai-gpt-5-nano` | Yes | +| `llama4-maverick`, `llama3.3-70b`, `llama3.1-70b`, `llama3.1-8b` | No | +| `mistral-large2`, `mistral-7b` | No | + +Snowflake deprecated `deepseek-r1`, `mistral-large`, `llama3.1-405b`, and `snowflake-llama-3.3-70b` on July 8, 2026, and has delisted `claude-3-7-sonnet`, `claude-3-5-sonnet`, `openai-gpt-5-chat`, `llama4-scout`, `mixtral-8x7b`, and `gemini-3.1-pro` — requests to these now fail. !!! note Model availability depends on your Snowflake region. Enable cross-region inference with `ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'ANY_REGION'` for full model access. @@ -311,7 +312,7 @@ Snowflake Cortex adds models faster than this list can be updated. If a model is } ``` -The entry merges with the built-in list, so the model appears in the picker and can be selected as `snowflake-cortex/your-new-model-id`. Set `"tool_call": false` for models that don't support tools on Cortex (Llama, Mistral, DeepSeek, Gemini today) — otherwise requests with tools will fail. +The entry merges with the built-in list, so the model appears in the picker and can be selected as `snowflake-cortex/your-new-model-id`. Set `"tool_call": false` for models that don't support tools on Cortex (Llama and Mistral today) — otherwise requests with tools will fail. The `tool_call` field uses snake_case (matching the rest of the `altimate-code.json` schema) and maps to the picker's `capabilities.toolcall`. The request transform reads the same value, so a user-added model marked `tool_call: true` keeps `tools` and `tool_choice` in outgoing requests — and one marked `tool_call: false` has them stripped, the same as the built-in non-tool entries. diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6e105aca1..279840d5f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1213,7 +1213,19 @@ export namespace Provider { env: [], options: {}, models: { + // Catalog verified live against a Cortex account on 2026-07-20 (each ID + // probed via /chat/completions). Removed IDs returned "unknown model" + // (delisted: claude-3-7-sonnet, claude-3-5-sonnet, openai-gpt-5-chat, + // openai-gpt-oss-120b, llama4-scout, snowflake-llama-3.1-405b, + // mixtral-8x7b, gemini-3.1-pro) or an explicit "deprecated on + // July 8, 2026" error (deepseek-r1, mistral-large, llama3.1-405b, + // snowflake-llama-3.3-70b). // Claude models — tool calling supported + "claude-sonnet-5": makeSnowflakeModel("claude-sonnet-5", "Claude Sonnet 5", { + context: 1000000, + output: 64000, + }), + "claude-opus-4-8": makeSnowflakeModel("claude-opus-4-8", "Claude Opus 4.8", { context: 1000000, output: 128000 }), "claude-opus-4-7": makeSnowflakeModel("claude-opus-4-7", "Claude Opus 4.7", { context: 1000000, output: 128000 }), "claude-sonnet-4-6": makeSnowflakeModel("claude-sonnet-4-6", "Claude Sonnet 4.6", { context: 200000, @@ -1231,17 +1243,25 @@ export namespace Provider { }), "claude-4-sonnet": makeSnowflakeModel("claude-4-sonnet", "Claude 4 Sonnet", { context: 200000, output: 64000 }), // claude-4-opus: documented but gated (403 "account not allowed" on tested accounts) - "claude-3-7-sonnet": makeSnowflakeModel("claude-3-7-sonnet", "Claude 3.7 Sonnet", { - context: 200000, - output: 16000, - }), - "claude-3-5-sonnet": makeSnowflakeModel("claude-3-5-sonnet", "Claude 3.5 Sonnet", { - context: 200000, - output: 8192, - }), + // claude-fable-5: ID recognized (maps to claude-fable-5-global) but + // "unavailable" on tested accounts — likely gated; add once it opens up. // OpenAI models — tool calling supported "openai-gpt-4.1": makeSnowflakeModel("openai-gpt-4.1", "OpenAI GPT-4.1", { context: 1047576, output: 32768 }), - // openai-gpt-5.2: not in Snowflake's per-model restrictions table; using + // openai-gpt-5.4 (plain): responds live but not yet in Snowflake's + // restrictions table; gpt-5 family defaults as best-effort. + "openai-gpt-5.4": makeSnowflakeModel("openai-gpt-5.4", "OpenAI GPT-5.4", { + context: 272000, + output: 8192, + }), + "openai-gpt-5.4-mini": makeSnowflakeModel("openai-gpt-5.4-mini", "OpenAI GPT-5.4 Mini", { + context: 400000, + output: 128000, + }), + "openai-gpt-5.4-nano": makeSnowflakeModel("openai-gpt-5.4-nano", "OpenAI GPT-5.4 Nano", { + context: 400000, + output: 128000, + }), + // openai-gpt-5.2 / 5.1: not in Snowflake's per-model restrictions table; // gpt-5 family defaults as best-effort until docs publish exact limits. "openai-gpt-5.2": makeSnowflakeModel("openai-gpt-5.2", "OpenAI GPT-5.2", { context: 272000, @@ -1260,11 +1280,6 @@ export namespace Provider { context: 1047576, output: 32768, }), - "openai-gpt-5-chat": makeSnowflakeModel("openai-gpt-5-chat", "OpenAI GPT-5 Chat", { - context: 1047576, - output: 32768, - }), - // openai-gpt-oss-120b: documented but returns 500 (not yet stable) // Meta Llama — no tool calling "llama4-maverick": makeSnowflakeModel( "llama4-maverick", @@ -1272,49 +1287,20 @@ export namespace Provider { { context: 1048576, output: 4096 }, { toolcall: false }, ), - "llama4-scout": makeSnowflakeModel( - "llama4-scout", - "Llama 4 Scout", - { context: 128000, output: 8192 }, - { toolcall: false }, - ), - // llama3.3-70b: upstream Meta-hosted variant. + // llama3.3-70b: region-gated ("enable cross region inference" error on + // out-of-region accounts) but still a live catalog entry — keep. "llama3.3-70b": makeSnowflakeModel( "llama3.3-70b", "Llama 3.3 70B", { context: 128000, output: 8192 }, { toolcall: false }, ), - // snowflake-llama-3.3-70b: Snowflake-hosted variant (different routing / - // region pinning vs the upstream `llama3.3-70b` above). - "snowflake-llama-3.3-70b": makeSnowflakeModel( - "snowflake-llama-3.3-70b", - "Snowflake Llama 3.3 70B", - { context: 128000, output: 8192 }, - { toolcall: false }, - ), - // snowflake-llama-3.1-405b: 8k context per Snowflake docs (much smaller - // than the upstream Meta model's window). Snowflake's table lists - // output=8192, but output cannot exceed the total token budget — cap - // at 4096 (the original sibling default) so prompt+output always fit. - "snowflake-llama-3.1-405b": makeSnowflakeModel( - "snowflake-llama-3.1-405b", - "Snowflake Llama 3.1 405B", - { context: 8000, output: 4096 }, - { toolcall: false }, - ), "llama3.1-70b": makeSnowflakeModel( "llama3.1-70b", "Llama 3.1 70B", { context: 128000, output: 4096 }, { toolcall: false }, ), - "llama3.1-405b": makeSnowflakeModel( - "llama3.1-405b", - "Llama 3.1 405B", - { context: 128000, output: 4096 }, - { toolcall: false }, - ), "llama3.1-8b": makeSnowflakeModel( "llama3.1-8b", "Llama 3.1 8B", @@ -1322,12 +1308,6 @@ export namespace Provider { { toolcall: false }, ), // Mistral — no tool calling - "mistral-large": makeSnowflakeModel( - "mistral-large", - "Mistral Large", - { context: 131000, output: 4096 }, - { toolcall: false }, - ), "mistral-large2": makeSnowflakeModel( "mistral-large2", "Mistral Large 2", @@ -1340,26 +1320,6 @@ export namespace Provider { { context: 32000, output: 4096 }, { toolcall: false }, ), - "mixtral-8x7b": makeSnowflakeModel( - "mixtral-8x7b", - "Mixtral 8x7B", - { context: 32000, output: 8192 }, - { toolcall: false }, - ), - // DeepSeek — no tool calling - "deepseek-r1": makeSnowflakeModel( - "deepseek-r1", - "DeepSeek R1", - { context: 64000, output: 32000 }, - { reasoning: true, toolcall: false }, - ), - // Gemini — tool calling not verified on Cortex; default to off until confirmed - "gemini-3.1-pro": makeSnowflakeModel( - "gemini-3.1-pro", - "Gemini 3.1 Pro", - { context: 1000000, output: 64000 }, - { toolcall: false }, - ), }, } // altimate_change end diff --git a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts index 9aa7db325..b8eda0f4e 100644 --- a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts +++ b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts @@ -33,7 +33,7 @@ import { // Production builds this set from `provider.models` at loader time; mirror // the same shape here for the transform-only e2e checks. const TOOLCAPABLE_E2E_FIXTURE: ReadonlySet = new Set([ - "claude-3-5-sonnet", "claude-sonnet-4-6", "claude-opus-4-7", + "claude-sonnet-5", "claude-sonnet-4-5", "claude-sonnet-4-6", "claude-opus-4-7", "claude-opus-4-8", "openai-gpt-4.1", "openai-gpt-5", ]) @@ -239,17 +239,15 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { // All models registered in provider.ts — availability depends on region/cross-region config const allModels = [ // Claude - "claude-sonnet-4-6", "claude-opus-4-6", "claude-sonnet-4-5", "claude-opus-4-5", - "claude-haiku-4-5", "claude-4-sonnet", "claude-4-opus", "claude-3-7-sonnet", "claude-3-5-sonnet", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6", + "claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5", "claude-4-sonnet", // OpenAI "openai-gpt-4.1", "openai-gpt-5", "openai-gpt-5-mini", "openai-gpt-5-nano", - "openai-gpt-5-chat", + "openai-gpt-5.4", // Meta Llama - "llama4-maverick", "snowflake-llama-3.3-70b", "llama3.1-70b", "llama3.1-405b", "llama3.1-8b", + "llama4-maverick", "llama3.3-70b", "llama3.1-70b", "llama3.1-8b", // Mistral - "mistral-large", "mistral-large2", "mistral-7b", - // DeepSeek - "deepseek-r1", + "mistral-large2", "mistral-7b", ] for (const model of allModels) { diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index fd291d619..96c716524 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -50,7 +50,7 @@ function provideProviderTestInstance(input: { // Production code derives the equivalent set from `provider.models` at loader // time; this fixture exists so unit tests of the pure transform stay simple. const TOOLCAPABLE_FIXTURE: ReadonlySet = new Set([ - "claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6", "claude-sonnet-4-5", + "claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6", "claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5", "claude-4-sonnet", "claude-3-7-sonnet", "claude-3-5-sonnet", "openai-gpt-4.1", "openai-gpt-5", "openai-gpt-5.1", "openai-gpt-5.2", @@ -955,9 +955,10 @@ describe("snowflake-cortex provider", () => { const providers = await Provider.list() const models = providers["snowflake-cortex"].models // Claude + expect(models["claude-sonnet-5"].capabilities.toolcall).toBe(true) + expect(models["claude-opus-4-8"].capabilities.toolcall).toBe(true) expect(models["claude-sonnet-4-6"].capabilities.toolcall).toBe(true) expect(models["claude-haiku-4-5"].capabilities.toolcall).toBe(true) - expect(models["claude-3-5-sonnet"].capabilities.toolcall).toBe(true) // OpenAI expect(models["openai-gpt-4.1"].capabilities.toolcall).toBe(true) expect(models["openai-gpt-5"].capabilities.toolcall).toBe(true) @@ -968,7 +969,7 @@ describe("snowflake-cortex provider", () => { } }) - test("Llama, Mistral, and DeepSeek models have toolcall: false", async () => { + test("Llama and Mistral models have toolcall: false", async () => { await setupOAuth() try { await using tmp = await tmpdir({ @@ -982,9 +983,8 @@ describe("snowflake-cortex provider", () => { const providers = await Provider.list() const models = providers["snowflake-cortex"].models expect(models["mistral-large2"].capabilities.toolcall).toBe(false) - expect(models["snowflake-llama-3.3-70b"].capabilities.toolcall).toBe(false) + expect(models["llama3.3-70b"].capabilities.toolcall).toBe(false) expect(models["llama3.1-70b"].capabilities.toolcall).toBe(false) - expect(models["deepseek-r1"].capabilities.toolcall).toBe(false) expect(models["llama4-maverick"].capabilities.toolcall).toBe(false) }, }) @@ -1036,11 +1036,10 @@ describe("snowflake-cortex provider", () => { } }) - test("models added per Snowflake regional availability docs (issue #851)", async () => { - // Regression: PR for issue #851 added 8 models that Snowflake Cortex - // supports but were missing from the hardcoded list. Lock in identity, - // toolcall capability, AND limits (the limits were corrected in the - // consensus-review follow-up after an initial drift was caught). + test("model catalog matches the live-verified Snowflake availability (2026-07-20)", async () => { + // Lock in identity, toolcall capability, AND limits for models verified + // live against Cortex (originally issue #851; catalog refreshed alongside + // the prompt-caching fix for issue #1009). await setupOAuth() try { await using tmp = await tmpdir({ config: {} }) @@ -1053,21 +1052,27 @@ describe("snowflake-cortex provider", () => { // Each entry: [id, expected toolcall, expected context, expected output] // Values sourced from // https://docs.snowflake.com/en/user-guide/snowflake-cortex/aisql-regional-availability - // (openai-gpt-5.2 is not in the restrictions table; using gpt-5 family defaults.) + // (openai-gpt-5.4/5.2/5.1 plain are not in the restrictions table; + // using gpt-5 family defaults.) const expectations: Array<[string, boolean, number, number]> = [ + ["claude-sonnet-5", true, 1000000, 64000], + ["claude-opus-4-8", true, 1000000, 128000], ["claude-opus-4-7", true, 1000000, 128000], ["openai-gpt-5.1", true, 272000, 8192], ["openai-gpt-5.2", true, 272000, 8192], - ["llama4-scout", false, 128000, 8192], + ["openai-gpt-5.4", true, 272000, 8192], + ["openai-gpt-5.4-mini", true, 400000, 128000], + ["openai-gpt-5.4-nano", true, 400000, 128000], ["llama3.3-70b", false, 128000, 8192], - // Snowflake docs list output=8192 for this model, but its context - // is only 8000 — capped at 4096 (sibling default) so prompt+output - // always fit. See provider.ts comment for the rationale. - ["snowflake-llama-3.1-405b", false, 8000, 4096], - ["mixtral-8x7b", false, 32000, 8192], - ["gemini-3.1-pro", false, 1000000, 64000], ] + // Delisted or Snowflake-deprecated models must be gone from the picker + for (const id of ["claude-3-5-sonnet", "claude-3-7-sonnet", "openai-gpt-5-chat", "llama4-scout", + "snowflake-llama-3.1-405b", "snowflake-llama-3.3-70b", "llama3.1-405b", "mixtral-8x7b", + "mistral-large", "deepseek-r1", "gemini-3.1-pro"]) { + expect(models[id], `model ${id} should be removed`).toBeUndefined() + } + for (const [id, toolcall, context, output] of expectations) { expect(models[id], `model ${id} should be defined`).toBeDefined() expect(models[id].capabilities.toolcall, `${id} toolcall`).toBe(toolcall) @@ -1288,7 +1293,7 @@ describe("snowflake-cortex provider", () => { } }) - test("claude-3-5-sonnet output limit is 8192", async () => { + test("claude-sonnet-5 limits match Snowflake's restrictions table", async () => { await setupOAuth() try { await using tmp = await tmpdir({ @@ -1300,7 +1305,8 @@ describe("snowflake-cortex provider", () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - expect(providers["snowflake-cortex"].models["claude-3-5-sonnet"].limit.output).toBe(8192) + expect(providers["snowflake-cortex"].models["claude-sonnet-5"].limit.context).toBe(1000000) + expect(providers["snowflake-cortex"].models["claude-sonnet-5"].limit.output).toBe(64000) }, }) } finally { @@ -1355,7 +1361,7 @@ describe("Provider.all() discoverability", () => { const models = allProviders["snowflake-cortex"]?.models expect(models).toBeDefined() expect(models["claude-sonnet-4-6"]).toBeDefined() - expect(models["deepseek-r1"]).toBeDefined() + expect(models["llama4-maverick"]).toBeDefined() }, }) }) From 10c67e591887ac2b5138f799d48a95b715115359 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 17:20:36 -0700 Subject: [PATCH 4/7] fix: [#1009] apply consensus code-review batch to Cortex caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the converged 10-reviewer consensus review (2 majors, 6 minors): - Sync `TOOLCAPABLE_FIXTURE` with the live catalog (drop `claude-3-7-sonnet`, `claude-3-5-sonnet`, `openai-gpt-5-chat`; add `claude-sonnet-5` and the `openai-gpt-5.4` family). - Replace the dead `DeepSeek R1 Reasoning` e2e block (passed vacuously via a status guard against the removed model) with a negative test asserting Cortex rejects the deprecated `deepseek-r1`. - Tighten the caching gate from `includes("claude")` to `startsWith("claude-")` — no false positives on user aliases. - Narrow the interceptor's catch to `SyntaxError`: non-JSON bodies still pass through, but transform bugs now surface instead of silently disabling both caching and the `max_tokens` rename. - Build the 400-retry body via `transformSnowflakeBody(text, toolCapable, false)` so the fallback request matches the clean disabled-mode shape; remove the now-unused `stripCacheControl` export. - Log a warning when caching is sticky-disabled (previously silent loss of the cache discount). The retry remains unscoped by design — Cortex's marker-rejection error text is undocumented, and a deterministic non-marker 400 recurs identically on the retry, never flipping the flag. - Add `openai-gpt-5.4-mini`/`-nano` to the e2e availability list; retarget the removed-model tool-strip unit test to `mistral-7b`. - Document `cacheApplied` in the `transformSnowflakeBody` JSDoc; add tests for the prefix gate and for a marker attaching to a trailing non-text block (previously untested path). Verified: 76 unit tests, live e2e 39 pass / 0 fail (key-pair JWT), typecheck and upstream marker check clean. Co-Authored-By: Claude Fable 5 --- .../opencode/src/altimate/plugin/snowflake.ts | 66 +++++++++++-------- .../altimate/cortex-snowflake-e2e.test.ts | 19 +++--- .../opencode/test/provider/snowflake.test.ts | 46 ++++++++----- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index 50b8c3143..d7c766997 100644 --- a/packages/opencode/src/altimate/plugin/snowflake.ts +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -1,5 +1,8 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Auth, OAUTH_DUMMY_KEY } from "@/auth" +import { Log } from "@/altimate/util/log" + +const log = Log.create({ service: "snowflake-cortex" }) /** * Build the set of Snowflake Cortex model IDs that support tool calling. @@ -92,7 +95,10 @@ function attachMarker(msg: Record, marker: Record): bo * Returns true when the request carries block-level markers after relocation. */ export function relocateCacheControl(parsed: Record): boolean { - if (typeof parsed.model !== "string" || !parsed.model.includes("claude")) return false + // Every Claude model Cortex serves uses a `claude-` prefixed ID; a prefix + // match avoids false positives on user-registered aliases that merely + // contain the substring. + if (typeof parsed.model !== "string" || !parsed.model.startsWith("claude-")) return false if (!Array.isArray(parsed.messages)) return false const messages = parsed.messages @@ -156,17 +162,6 @@ function stripParsedCacheControl(parsed: Record) { } } -/** Remove every cache marker from a request body (fallback when Cortex rejects them). */ -export function stripCacheControl(bodyText: string): string { - try { - const parsed = JSON.parse(bodyText) - stripParsedCacheControl(parsed) - return JSON.stringify(parsed) - } catch { - return bodyText - } -} - /** Parse a `account::token` PAT credential string. */ export function parseSnowflakePAT(code: string): { account: string; token: string } | null { const sep = code.indexOf("::") @@ -180,7 +175,6 @@ export function parseSnowflakePAT(code: string): { account: string; token: strin /** * Transform a Snowflake Cortex request body string. - * Returns a Response to short-circuit the fetch (synthetic stop), or undefined to continue normally. * * @param toolCapable Model IDs that should retain `tools` / `tool_choice` / tool messages. * Build via `buildToolCapableSet(provider.models)` at loader time so @@ -188,6 +182,10 @@ export function parseSnowflakePAT(code: string): { account: string; token: strin * @param cacheControl When false, strip every cache marker instead of relocating * them into content blocks (set after Cortex rejected a * cache-marked request). + * @returns `body` — the rewritten request body; `syntheticStop` — a Response that + * short-circuits the fetch entirely (trailing-assistant continuation); + * `cacheApplied` — true when the body carries block-level cache markers. + * @throws SyntaxError when `bodyText` is not valid JSON. */ export function transformSnowflakeBody( bodyText: string, @@ -303,27 +301,30 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise {}) return retry } diff --git a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts index b8eda0f4e..75ba0c5c2 100644 --- a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts +++ b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts @@ -243,7 +243,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { "claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5", "claude-4-sonnet", // OpenAI "openai-gpt-4.1", "openai-gpt-5", "openai-gpt-5-mini", "openai-gpt-5-nano", - "openai-gpt-5.4", + "openai-gpt-5.4", "openai-gpt-5.4-mini", "openai-gpt-5.4-nano", // Meta Llama "llama4-maverick", "llama3.3-70b", "llama3.1-70b", "llama3.1-8b", // Mistral @@ -322,21 +322,20 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { }) // ------------------------------------------------------------------------- - // DeepSeek R1 reasoning format + // Removed models must stay removed // ------------------------------------------------------------------------- - describe("DeepSeek R1 Reasoning", () => { - test("deepseek-r1 returns tags in content", async () => { + describe("Removed Models", () => { + test("deprecated deepseek-r1 is rejected by Cortex", async () => { + // Snowflake deprecated deepseek-r1 on July 8, 2026 and it was removed + // from the catalog. If this test ever sees a 200, Cortex reinstated the + // model and the catalog should be revisited. const resp = await cortexChat({ model: "deepseek-r1", messages: [{ role: "user", content: "What is 2+2?" }], stream: false, - max_tokens: 64, + max_tokens: 16, }) - if (resp.status === 200) { - const json = await resp.json() - const content = json.choices[0].message.content - expect(content).toContain("") - } + expect([400, 403]).toContain(resp.status) }, 30000) }) diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index 96c716524..63f5b9978 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -14,7 +14,6 @@ import { parseSnowflakePAT, relocateCacheControl, SnowflakeCortexAuthPlugin, - stripCacheControl, transformSnowflakeBody, } from "../../src/altimate/plugin/snowflake" @@ -50,11 +49,10 @@ function provideProviderTestInstance(input: { // Production code derives the equivalent set from `provider.models` at loader // time; this fixture exists so unit tests of the pure transform stay simple. const TOOLCAPABLE_FIXTURE: ReadonlySet = new Set([ - "claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6", "claude-sonnet-4-5", - "claude-opus-4-5", "claude-haiku-4-5", "claude-4-sonnet", "claude-3-7-sonnet", - "claude-3-5-sonnet", - "openai-gpt-4.1", "openai-gpt-5", "openai-gpt-5.1", "openai-gpt-5.2", - "openai-gpt-5-mini", "openai-gpt-5-nano", "openai-gpt-5-chat", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6", + "claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5", "claude-4-sonnet", + "openai-gpt-4.1", "openai-gpt-5.4", "openai-gpt-5.4-mini", "openai-gpt-5.4-nano", + "openai-gpt-5.2", "openai-gpt-5.1", "openai-gpt-5", "openai-gpt-5-mini", "openai-gpt-5-nano", ]) // --------------------------------------------------------------------------- @@ -166,9 +164,9 @@ describe("transformSnowflakeBody", () => { expect(parsed.tools).toBeUndefined() }) - test("strips tools for deepseek-r1", () => { + test("strips tools for mistral-7b", () => { const input = JSON.stringify({ - model: "deepseek-r1", + model: "mistral-7b", messages: [{ role: "user", content: "hello" }], tools: [{ type: "function", function: { name: "read_file" } }], }) @@ -698,17 +696,33 @@ describe("relocateCacheControl", () => { } }) - test("stripCacheControl removes every marker from the body", () => { - const body = JSON.stringify({ + test("prefix gate: model containing but not starting with 'claude' is untouched", () => { + const parsed: Record = { + model: "my-claude-finetune", + messages: [{ role: "system", content: "sys", cache_control: EPHEMERAL }], + } + expect(relocateCacheControl(parsed)).toBe(false) + expect(parsed.messages[0].cache_control).toEqual(EPHEMERAL) + }) + + test("message-level marker attaches to a trailing non-text block", () => { + // Locks current behavior: relocation targets the last non-empty block + // regardless of type (live verification only covered text blocks). + const parsed: Record = { model: "claude-opus-4-7", messages: [ - { role: "system", content: [{ type: "text", text: "sys", cache_control: EPHEMERAL }] }, - { role: "user", content: "hi", cache_control: EPHEMERAL }, + { + role: "user", + content: [ + { type: "text", text: "describe this" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ], + cache_control: EPHEMERAL, + }, ], - }) - const parsed = JSON.parse(stripCacheControl(body)) - expect("cache_control" in parsed.messages[0].content[0]).toBe(false) - expect("cache_control" in parsed.messages[1]).toBe(false) + } + expect(relocateCacheControl(parsed)).toBe(true) + expect(parsed.messages[0].content[1].cache_control).toEqual(EPHEMERAL) }) }) From 1912781896998c1a4402f0a4b512db0764ae2cf1 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 19:02:42 -0700 Subject: [PATCH 5/7] fix: [#1009] time-box the cache-disable fallback to a 5-minute cooldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 400-fallback previously disabled marker injection for the REST of the session — a false trip (transient 400 whose stripped retry happened to succeed) would silently bill every subsequent request at the uncached rate, reintroducing the exact cost blowout this fix exists to prevent. Replace the session-permanent flag with `cacheDisabledUntil`, a cooldown deadline set to `CACHE_DISABLE_COOLDOWN_MS` (5 minutes — Cortex's ephemeral cache TTL, so by expiry the cache would be cold anyway). This bounds both failure modes: - False trip: caching self-heals after <= 5 minutes instead of never. - Account that genuinely rejects markers (hypothetical — every live probe accepted them): one wasted marked-attempt + retry per cooldown period. Cooldown test uses `setSystemTime` to verify markers resume after expiry. Co-Authored-By: Claude Fable 5 --- .../opencode/src/altimate/plugin/snowflake.ts | 25 +++++++++++++------ .../opencode/test/provider/snowflake.test.ts | 14 ++++++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index d7c766997..30e83440c 100644 --- a/packages/opencode/src/altimate/plugin/snowflake.ts +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -46,6 +46,16 @@ export const VALID_ACCOUNT_RE = /^[a-zA-Z0-9._-]+$/ /** Snowflake Cortex accepts at most 4 cache breakpoints per request (Anthropic limit). */ const CACHE_BREAKPOINT_LIMIT = 4 +/** + * How long to stop injecting cache markers after Cortex rejects a cache-marked + * request. Matches Cortex's 5-minute ephemeral cache TTL: by the time the + * cooldown expires the cache would be cold anyway, so re-trying markers costs + * almost nothing — while a false trip (transient 400 whose stripped retry + * happened to succeed) self-heals instead of silently billing the whole + * session at the uncached rate. + */ +const CACHE_DISABLE_COOLDOWN_MS = 5 * 60 * 1000 + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -273,8 +283,8 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise= cacheDisabledUntil) } catch (error) { // Non-JSON body — pass through untransformed. Anything else // is a transform bug and must surface, not degrade silently. @@ -332,19 +342,20 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise {}) return retry diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index 63f5b9978..7ce2945dd 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, setSystemTime, test } from "bun:test" import path from "path" import { generateText, type ModelMessage } from "ai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" @@ -801,7 +801,7 @@ describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { ], }) - test("retries once without cache markers on 400 and disables caching for the session", async () => { + test("retries once without cache markers on 400 and pauses caching for a cooldown", async () => { const pluginFetch = await makeLoaderFetch() const bodies: string[] = [] const originalFetch = globalThis.fetch @@ -824,12 +824,20 @@ describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { expect(hasBlockMarkers(bodies[0])).toBe(true) expect(hasBlockMarkers(bodies[1])).toBe(false) - // Sticky disable: the next request carries no markers at all + // Within the cooldown: the next request carries no markers at all const second = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) expect(second.status).toBe(200) expect(bodies).toHaveLength(3) expect(bodies[2]).not.toContain("cache_control") + + // After the cooldown (5 min) elapses, marker injection resumes — + // a false trip self-heals instead of running the whole session uncached + setSystemTime(new Date(Date.now() + 5 * 60 * 1000 + 1000)) + const third = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(third.status).toBe(200) + expect(hasBlockMarkers(bodies[3])).toBe(true) } finally { + setSystemTime() globalThis.fetch = originalFetch } }) From 96280da310a909c101efb621dfaa1b505d24eec9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 19:03:35 -0700 Subject: [PATCH 6/7] docs: CHANGELOG reflects cooldown-based cache fallback Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3519b11f..30f9008f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Snowflake Cortex prompt caching now activates for Claude models.** Cortex only honors caching markers placed inside content blocks (`messages[].content[].cache_control`), but requests carried them as message-level fields — so every request billed the full input rate (`cache_read_input`/`cache_write_input` stayed NULL in `TOKENS_GRANULAR`). The provider now relocates the markers into content blocks (system prompt + trailing messages, max 4 breakpoints), cutting repeated-prefix input cost by up to 90% on long agent sessions. If a Cortex account rejects the marked shape, the request is retried once without markers and caching is disabled for the session. (#1009) +- **Snowflake Cortex prompt caching now activates for Claude models.** Cortex only honors caching markers placed inside content blocks (`messages[].content[].cache_control`), but requests carried them as message-level fields — so every request billed the full input rate (`cache_read_input`/`cache_write_input` stayed NULL in `TOKENS_GRANULAR`). The provider now relocates the markers into content blocks (system prompt + trailing messages, max 4 breakpoints), cutting repeated-prefix input cost by up to 90% on long agent sessions. If a Cortex account rejects the marked shape, the request is retried once without markers and marker injection pauses for a 5-minute cooldown. (#1009) ## [0.9.1] - 2026-07-08 From 84bc9fa0cfc2d0379f4ff4a2b5c7563ae1305426 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 20 Jul 2026 19:23:41 -0700 Subject: [PATCH 7/7] fix: [#1009] address PR review-bot findings on the cache fallback - Close the concurrency window (kilo-code-bot, cubic): the cooldown is now set eagerly when a cache-marked 400 is detected, before the probe retry runs, so concurrent in-flight requests stop sending the rejected shape immediately; it is rolled back if the retry shows markers weren't the cause (retry also fails) or the probe hits a transport error. - Preserve the original 400 when the probe retry fetch throws (Codex, OpenCodeReview): the unguarded `await fetch` could replace Cortex's diagnostic with a network exception; the retry is now wrapped and the original response returned on transport failure. - Scope disabled-mode stripping to Claude models (cubic): non-Claude payloads pass through untouched during a cooldown instead of having their (Cortex-ignored) markers removed. - Consume the first response body in the caching e2e test (CodeRabbit); document the deliberate `any` in `isRecord` (OpenCodeReview). New tests: disabled-mode non-Claude passthrough; retry-fetch-throws returns the original 400 and rolls back the cooldown. 78 unit / 0 fail; live e2e 39 / 0 fail; typecheck + marker check clean. Co-Authored-By: Claude Fable 5 --- .../opencode/src/altimate/plugin/snowflake.ts | 28 +++++++++++-- .../altimate/cortex-snowflake-e2e.test.ts | 1 + .../opencode/test/provider/snowflake.test.ts | 39 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index 30e83440c..bc5a8ed03 100644 --- a/packages/opencode/src/altimate/plugin/snowflake.ts +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -56,6 +56,8 @@ const CACHE_BREAKPOINT_LIMIT = 4 */ const CACHE_DISABLE_COOLDOWN_MS = 5 * 60 * 1000 +// `any` is deliberate: these guards navigate arbitrary request-body JSON whose +// message/block properties are read and mutated without a fixed schema. function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -211,8 +213,13 @@ export function transformSnowflakeBody( } let cacheApplied = false - if (cacheControl) cacheApplied = relocateCacheControl(parsed) - else stripParsedCacheControl(parsed) + if (cacheControl) { + cacheApplied = relocateCacheControl(parsed) + } else if (typeof parsed.model === "string" && parsed.model.startsWith("claude-")) { + // Disabled mode only unwinds what relocation would have produced — keep + // non-Claude payloads untouched (Cortex ignores their markers anyway). + stripParsedCacheControl(parsed) + } // Strip tools for models that don't support tool calling on Snowflake Cortex. // Also remove orphaned tool_calls from messages to avoid Snowflake API errors. @@ -350,9 +357,20 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise {}) return retry } + // Retry failed too — the 400 wasn't the markers; resume injection. + cacheDisabledUntil = previousDisabledUntil void retry.body?.cancel().catch(() => {}) return response } diff --git a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts index 75ba0c5c2..71f7e3031 100644 --- a/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts +++ b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts @@ -397,6 +397,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { const first = await request() expect(first.status).toBe(200) + await first.text() const second = await request() expect(second.status).toBe(200) const usage = ((await second.json()) as any).usage diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index 7ce2945dd..13e884d3d 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -696,6 +696,16 @@ describe("relocateCacheControl", () => { } }) + test("disabled mode leaves non-claude payloads untouched", () => { + const input = JSON.stringify({ + model: "llama4-maverick", + messages: [{ role: "system", content: [{ type: "text", text: "sys", cache_control: EPHEMERAL }] }], + }) + const result = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE, false) + const parsed = JSON.parse(result.body) + expect(parsed.messages[0].content[0].cache_control).toEqual(EPHEMERAL) + }) + test("prefix gate: model containing but not starting with 'claude' is untouched", () => { const parsed: Record = { model: "my-claude-finetune", @@ -865,6 +875,35 @@ describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { globalThis.fetch = originalFetch } }) + + test("returns the original 400 and keeps caching enabled when the retry fetch throws", async () => { + const pluginFetch = await makeLoaderFetch() + const bodies: string[] = [] + const originalFetch = globalThis.fetch + let calls = 0 + globalThis.fetch = (async (_input: any, init?: RequestInit) => { + bodies.push(String(init?.body)) + calls += 1 + if (calls === 2) throw new TypeError("network down") + if (calls === 1) return new Response("{}", { status: 400 }) + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }) + }) as typeof fetch + + try { + const url = "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1/chat/completions" + // Transport failure on the probe retry surfaces the original Cortex 400 + const first = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(first.status).toBe(400) + expect(bodies).toHaveLength(2) + + // The eager cooldown was rolled back — markers keep flowing + const second = await pluginFetch(url, { method: "POST", body: cacheMarkedBody() }) + expect(second.status).toBe(200) + expect(bodies[2]).toContain("cache_control") + } finally { + globalThis.fetch = originalFetch + } + }) }) // ---------------------------------------------------------------------------