diff --git a/CHANGELOG.md b/CHANGELOG.md index d52e10dc96..30f9008f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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] + +### 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 marker injection pauses for a 5-minute cooldown. (#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 0689a6a0b1..0ad0dc00d3 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -276,15 +276,18 @@ Create a PAT in Snowsight: **Admin > Security > Programmatic Access Tokens**. Billing flows through your Snowflake credits — no per-token costs. -**Available models:** +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** (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. @@ -309,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/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index 8528a0c328..bc5a8ed030 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. @@ -40,6 +43,137 @@ 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 + +/** + * 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 + +// `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) +} + +/** 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) — 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 { + // 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 + + 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. + 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 + + 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 messages) { + if (!isRecord(msg) || !Array.isArray(msg.content)) continue + for (const block of msg.content) { + 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))) { + 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 + } + } +} + /** Parse a `account::token` PAT credential string. */ export function parseSnowflakePAT(code: string): { account: string; token: string } | null { const sep = code.indexOf("::") @@ -53,16 +187,23 @@ 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 * 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). + * @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, 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 +212,15 @@ export function transformSnowflakeBody( delete parsed.max_tokens } + let cacheApplied = false + 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. if (!toolCapable.has(parsed.model)) { @@ -105,6 +255,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 +264,7 @@ export function transformSnowflakeBody( } } - return { body: JSON.stringify(parsed) } + return { body: JSON.stringify(parsed), cacheApplied } } export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise { @@ -137,6 +288,11 @@ 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. + if (!(error instanceof SyntaxError)) throw error } - if (text) { - const result = transformSnowflakeBody(text, toolCapable) + if (result) { if (result.syntheticStop) return result.syntheticStop body = result.body + cacheApplied = result.cacheApplied === true headers.delete("content-length") } - } catch { - // JSON parse error — pass original body through untransformed } } - return fetch(requestInput, { ...init, headers, body }) + const response = await fetch(requestInput, { ...init, headers, body }) + + // If Cortex rejects a cache-marked request, retry once without the + // markers; when the stripped retry succeeds, the markers were the + // problem — pause marker injection for a cooldown period. The retry + // fires on any 400 (Cortex's marker-rejection error text is + // undocumented, so there is no reliable signal to scope on); a + // deterministic non-marker 400 recurs identically on the retry and + // never trips the cooldown. + if (response.status === 400 && cacheApplied) { + const stripped = transformSnowflakeBody(text, toolCapable, false).body + if (stripped !== body) { + // Pause markers eagerly so concurrent in-flight requests stop + // sending the rejected shape while this probe retry runs; + // restored below if the retry shows markers weren't the cause. + const previousDisabledUntil = cacheDisabledUntil + cacheDisabledUntil = Date.now() + CACHE_DISABLE_COOLDOWN_MS + let retry: Response + try { + retry = await fetch(requestInput, { ...init, headers, body: stripped }) + } catch { + // Transport failure on the probe — surface the original 400. + cacheDisabledUntil = previousDisabledUntil + return response + } + if (retry.ok) { + log.warn("Cortex rejected cache-marked request; pausing prompt caching", { + status: response.status, + cooldownMs: CACHE_DISABLE_COOLDOWN_MS, + }) + void response.body?.cancel().catch(() => {}) + return retry + } + // Retry failed too — the 400 wasn't the markers; resume injection. + cacheDisabledUntil = previousDisabledUntil + void retry.body?.cancel().catch(() => {}) + return response + } + } + + return response }, } }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6e105aca12..279840d5fe 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 3603a3478d..71f7e30318 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", ]) @@ -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, @@ -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", "openai-gpt-5.4-mini", "openai-gpt-5.4-nano", // 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) { @@ -278,7 +276,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 () => { @@ -324,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) }) @@ -354,7 +351,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" }, @@ -371,13 +368,130 @@ 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-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 () => { + 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. 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) + await first.text() + 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-sonnet-4-5", + 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, + }), + }) + // 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) + }) + // ------------------------------------------------------------------------- // Request transforms (unit-level, no network) // ------------------------------------------------------------------------- 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, @@ -403,7 +517,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" }, @@ -416,7 +530,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 62a89342ec..13e884d3d2 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -1,12 +1,21 @@ -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" +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, + transformSnowflakeBody, +} from "../../src/altimate/plugin/snowflake" function provideProviderTestInstance(input: { directory: string @@ -40,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-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", ]) // --------------------------------------------------------------------------- @@ -156,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" } }], }) @@ -384,6 +392,350 @@ 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("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: "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) + // 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", () => { + 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) + // 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).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) + } + }) + + 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", + 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: "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) + }) +}) + // --------------------------------------------------------------------------- // Fetch interceptor (SnowflakeCortexAuthPlugin) // --------------------------------------------------------------------------- @@ -433,6 +785,125 @@ 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 pauses caching for a cooldown", 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) + + // 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 + } + }) + + 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 + } + }) + + 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 + } + }) }) // --------------------------------------------------------------------------- @@ -545,9 +1016,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) @@ -558,7 +1030,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({ @@ -572,9 +1044,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) }, }) @@ -626,11 +1097,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: {} }) @@ -643,21 +1113,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) @@ -878,7 +1354,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({ @@ -890,7 +1366,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 { @@ -945,7 +1422,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() }, }) })