diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 6c13590ca..be552e6de 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -280,15 +280,39 @@ Billing flows through your Snowflake credits — no per-token costs. | Model | Tool Calling | |-------|-------------| -| `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-mini`, `openai-gpt-5-nano`, `openai-gpt-5-chat` | Yes | -| `llama4-maverick`, `snowflake-llama-3.3-70b`, `llama3.1-70b`, `llama3.1-405b`, `llama3.1-8b` | No | -| `mistral-large`, `mistral-large2`, `mistral-7b` | No | -| `deepseek-r1` | No | +| `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 | !!! 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. +### Adding a model not in the list + +Snowflake Cortex adds models faster than this list can be updated. If a model is available on your Cortex account but not yet listed above, you can register it locally without forking the CLI — add it under `provider["snowflake-cortex"].models` in your `altimate-code.json` (or `.altimate-code/altimate-code.json`): + +```json +{ + "provider": { + "snowflake-cortex": { + "models": { + "your-new-model-id": { + "name": "Your New Model", + "limit": { "context": 200000, "output": 32000 }, + "tool_call": true + } + } + } + } +} +``` + +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 `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. + ## Databricks AI Gateway Connect to Databricks serving endpoints (Foundation Model APIs) via your workspace PAT. Use Databricks-hosted Llama, Claude, GPT, Gemini, DBRX, or Mixtral for agent reasoning — billing flows through your Databricks account. diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts index 79a6bb112..8528a0c32 100644 --- a/packages/opencode/src/altimate/plugin/snowflake.ts +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -1,16 +1,41 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Auth, OAUTH_DUMMY_KEY } from "@/auth" -// Only OpenAI and Claude models support tool calling on Snowflake Cortex. -// All other models reject tools with "tool calling is not supported". -const TOOLCALL_MODELS = new Set([ - // 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", - // OpenAI - "openai-gpt-4.1", "openai-gpt-5", "openai-gpt-5-mini", "openai-gpt-5-nano", - "openai-gpt-5-chat", "openai-gpt-oss-120b", "openai-o4-mini", -]) +/** + * Build the set of Snowflake Cortex model IDs that support tool calling. + * Derived from `provider.models[*].capabilities.toolcall` so the picker's + * advertised capability and the request-transform's behavior cannot drift — + * adding a tool-capable model to the provider definition (or registering one + * via `altimate-code.json`) is the single source of truth. + * + * Indexes both the picker key and the model's `api.id` (and `id`) so that + * aliased models — where a user registers e.g. `"my-alias": { "id": + * "claude-opus-4-7" }` in their config — match correctly. The transform + * compares against `parsed.model` which is the API id sent in the request + * body, not the picker map key. + * + * Snowflake's documented constraint: only Claude and OpenAI models accept + * tools on Cortex; everything else rejects with "tool calling is not supported." + */ +export function buildToolCapableSet( + models: Record< + string, + { + id?: string + api?: { id?: string } + capabilities: { toolcall: boolean } + } + >, +): ReadonlySet { + const set = new Set() + for (const [key, m] of Object.entries(models)) { + if (!m.capabilities.toolcall) continue + set.add(key) + if (m.id) set.add(m.id) + if (m.api?.id) set.add(m.api.id) + } + return set +} /** Snowflake account identifiers contain only alphanumeric, hyphen, underscore, and dot characters. */ export const VALID_ACCOUNT_RE = /^[a-zA-Z0-9._-]+$/ @@ -29,8 +54,15 @@ 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. */ -export function transformSnowflakeBody(bodyText: string): { body: string; syntheticStop?: Response } { +export function transformSnowflakeBody( + bodyText: string, + toolCapable: ReadonlySet, +): { body: string; syntheticStop?: Response } { const parsed = JSON.parse(bodyText) // Snowflake uses max_completion_tokens instead of max_tokens @@ -41,7 +73,7 @@ export function transformSnowflakeBody(bodyText: string): { body: string; synthe // 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 (!TOOLCALL_MODELS.has(parsed.model)) { + if (!toolCapable.has(parsed.model)) { delete parsed.tools delete parsed.tool_choice if (Array.isArray(parsed.messages)) { @@ -97,6 +129,14 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise = new Set([ + "claude-3-5-sonnet", "claude-sonnet-4-6", "claude-opus-4-7", + "openai-gpt-4.1", "openai-gpt-5", +]) + // --------------------------------------------------------------------------- // Auth configuration // --------------------------------------------------------------------------- @@ -375,7 +382,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { max_tokens: 100, stream: true, }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_E2E_FIXTURE) const parsed = JSON.parse(body) expect(parsed.max_completion_tokens).toBe(100) expect(parsed.max_tokens).toBeUndefined() @@ -388,7 +395,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { tools: [{ type: "function", function: { name: "read_file" } }], tool_choice: "auto", }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_E2E_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeUndefined() expect(parsed.tool_choice).toBeUndefined() @@ -403,7 +410,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { { role: "assistant", content: "response" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_E2E_FIXTURE) expect(syntheticStop).toBeUndefined() }) @@ -416,7 +423,7 @@ describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { { role: "assistant", content: "response" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_E2E_FIXTURE) expect(syntheticStop).toBeDefined() expect(syntheticStop!.status).toBe(200) }) diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts index f4e20db74..2b09ff9e2 100644 --- a/packages/opencode/test/provider/snowflake.test.ts +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -5,7 +5,19 @@ import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { Auth } from "../../src/auth" import { Env } from "../../src/env" -import { parseSnowflakePAT, transformSnowflakeBody } from "../../src/altimate/plugin/snowflake" +import { buildToolCapableSet, parseSnowflakePAT, transformSnowflakeBody } from "../../src/altimate/plugin/snowflake" + +// Fixture allowlist for transformSnowflakeBody unit tests. Reflects what +// Snowflake Cortex actually accepts tools for today (Claude + OpenAI families). +// 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", +]) // --------------------------------------------------------------------------- // parseSnowflakePAT @@ -78,7 +90,7 @@ describe("parseSnowflakePAT", () => { describe("transformSnowflakeBody", () => { test("rewrites max_tokens to max_completion_tokens", () => { const input = JSON.stringify({ model: "claude-sonnet-4-6", messages: [], max_tokens: 1000 }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.max_completion_tokens).toBe(1000) expect(parsed.max_tokens).toBeUndefined() @@ -86,7 +98,7 @@ describe("transformSnowflakeBody", () => { test("leaves requests without max_tokens unchanged", () => { const input = JSON.stringify({ model: "claude-sonnet-4-6", messages: [], max_completion_tokens: 1000 }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.max_completion_tokens).toBe(1000) expect(parsed.max_tokens).toBeUndefined() @@ -99,7 +111,7 @@ describe("transformSnowflakeBody", () => { tools: [{ type: "function", function: { name: "read_file" } }], tool_choice: "auto", }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeUndefined() expect(parsed.tool_choice).toBeUndefined() @@ -111,7 +123,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "hello" }], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeUndefined() }) @@ -122,7 +134,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "hello" }], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeUndefined() }) @@ -133,7 +145,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "hello" }], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeDefined() expect(parsed.tools).toHaveLength(1) @@ -145,7 +157,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "hello" }], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeDefined() expect(parsed.tools).toHaveLength(1) @@ -160,7 +172,7 @@ describe("transformSnowflakeBody", () => { ], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeDefined() expect(syntheticStop!.status).toBe(200) expect(syntheticStop!.headers.get("content-type")).toBe("text/event-stream") @@ -175,7 +187,7 @@ describe("transformSnowflakeBody", () => { ], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeUndefined() }) @@ -185,7 +197,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "test" }], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeUndefined() }) @@ -198,7 +210,7 @@ describe("transformSnowflakeBody", () => { { role: "assistant", content: "I'm here!" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeUndefined() }) @@ -211,7 +223,7 @@ describe("transformSnowflakeBody", () => { { role: "assistant", content: "I'm here!" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeDefined() }) @@ -223,7 +235,7 @@ describe("transformSnowflakeBody", () => { { role: "assistant", content: "I'm here!" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeDefined() }) @@ -235,7 +247,7 @@ describe("transformSnowflakeBody", () => { { role: "assistant", content: "done", tool_calls: [] }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeDefined() }) @@ -250,7 +262,7 @@ describe("transformSnowflakeBody", () => { ], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeUndefined() // tool_calls should be removed from assistant messages @@ -262,7 +274,7 @@ describe("transformSnowflakeBody", () => { }) test("throws on invalid JSON input", () => { - expect(() => transformSnowflakeBody("not-json")).toThrow() + expect(() => transformSnowflakeBody("not-json", TOOLCAPABLE_FIXTURE)).toThrow() }) test("synthetic stop SSE stream has correct format", async () => { @@ -273,7 +285,7 @@ describe("transformSnowflakeBody", () => { { role: "assistant", content: "done" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeDefined() const text = await syntheticStop!.text() // Should contain SSE data lines and [DONE] @@ -286,13 +298,13 @@ describe("transformSnowflakeBody", () => { test("handles empty messages array without crashing", () => { const input = JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeUndefined() }) test("handles missing messages field", () => { const input = JSON.stringify({ model: "claude-sonnet-4-6" }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(JSON.parse(body).model).toBe("claude-sonnet-4-6") }) @@ -302,7 +314,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "test" }], max_completion_tokens: 500, }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.max_completion_tokens).toBe(500) expect(parsed.max_tokens).toBeUndefined() @@ -315,7 +327,7 @@ describe("transformSnowflakeBody", () => { max_tokens: 100, max_completion_tokens: 500, }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.max_completion_tokens).toBe(100) expect(parsed.max_tokens).toBeUndefined() @@ -327,7 +339,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "hello" }], tools: [{ type: "function", function: { name: "read_file" } }], }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tools).toBeUndefined() }) @@ -338,7 +350,7 @@ describe("transformSnowflakeBody", () => { messages: [{ role: "user", content: "hello" }], tool_choice: "auto", }) - const { body } = transformSnowflakeBody(input) + const { body } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) const parsed = JSON.parse(body) expect(parsed.tool_choice).toBeUndefined() }) @@ -362,7 +374,7 @@ describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { }) // Transform body (same logic as the fetch wrapper) - const result = transformSnowflakeBody(originalBody) + const result = transformSnowflakeBody(originalBody, TOOLCAPABLE_FIXTURE) const newBody = result.body // Body changed (max_tokens → max_completion_tokens), so lengths differ @@ -382,7 +394,7 @@ describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { { role: "assistant", content: "response" }, ], }) - const { syntheticStop } = transformSnowflakeBody(input) + const { syntheticStop } = transformSnowflakeBody(input, TOOLCAPABLE_FIXTURE) expect(syntheticStop).toBeInstanceOf(Response) expect(syntheticStop!.status).toBe(200) expect(syntheticStop!.headers.get("content-type")).toBe("text/event-stream") @@ -586,6 +598,258 @@ 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). + await setupOAuth() + try { + await using tmp = await tmpdir({ config: {} }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const models = providers["snowflake-cortex"].models + + // 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.) + const expectations: Array<[string, boolean, number, number]> = [ + ["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], + ["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], + ] + + 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) + expect(models[id].limit.context, `${id} context`).toBe(context) + expect(models[id].limit.output, `${id} output`).toBe(output) + } + }, + }) + } finally { + await restoreAuth() + } + }) + + test("buildToolCapableSet derives the allowlist from provider model capabilities", async () => { + // Source-of-truth test for the escape-hatch fix: the request transform + // gets its allowlist from `provider.models.capabilities.toolcall` rather + // than a separate hardcoded set in snowflake.ts. Models added via + // altimate-code.json with `tool_call: true` therefore retain tools at + // request time, and the picker capability cannot drift from the transform. + await setupOAuth() + try { + await using tmp = await tmpdir({ config: {} }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const set = buildToolCapableSet(providers["snowflake-cortex"].models) + // Every model with capabilities.toolcall === true is in the set; the rest are not. + for (const [id, m] of Object.entries(providers["snowflake-cortex"].models)) { + expect(set.has(id), `${id} parity`).toBe(m.capabilities.toolcall) + } + }, + }) + } finally { + await restoreAuth() + } + }) + + test("escape-hatch model with tool_call: true retains tools through transformSnowflakeBody", async () => { + // The documented altimate-code.json escape hatch must work end-to-end: + // picker shows the model as tool-capable AND the request transform passes + // tools through. Without the loader-derived allowlist this test would fail + // because the static set never sees user-added entries. + await setupOAuth() + try { + await using tmp = await tmpdir({ + config: { + provider: { + "snowflake-cortex": { + models: { + "user-tool-model": { + name: "User Tool Model", + limit: { context: 100000, output: 8192 }, + tool_call: true, + }, + }, + }, + }, + } as any, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const toolCapable = buildToolCapableSet(providers["snowflake-cortex"].models) + const input = JSON.stringify({ + model: "user-tool-model", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "read_file" } }], + tool_choice: "auto", + }) + const { body } = transformSnowflakeBody(input, toolCapable) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeDefined() + expect(parsed.tools).toHaveLength(1) + expect(parsed.tool_choice).toBe("auto") + }, + }) + } finally { + await restoreAuth() + } + }) + + test("escape-hatch model with tool_call: false has tools stripped through transformSnowflakeBody", async () => { + // Counterpart to the above: a user-registered non-tool model gets the + // tools stripped just like the built-in Llama/Mistral entries do. + await setupOAuth() + try { + await using tmp = await tmpdir({ + config: { + provider: { + "snowflake-cortex": { + models: { + "user-notool-model": { + name: "User No-Tool Model", + limit: { context: 32000, output: 4096 }, + tool_call: false, + }, + }, + }, + }, + } as any, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const toolCapable = buildToolCapableSet(providers["snowflake-cortex"].models) + const input = JSON.stringify({ + model: "user-notool-model", + messages: [ + { role: "user", content: "hi" }, + { role: "tool", content: "x", tool_call_id: "t1" }, + ], + tools: [{ type: "function", function: { name: "read_file" } }], + tool_choice: "auto", + }) + const { body } = transformSnowflakeBody(input, toolCapable) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + expect(parsed.tool_choice).toBeUndefined() + // Orphaned tool messages dropped too. + expect(parsed.messages.find((m: { role: string }) => m.role === "tool")).toBeUndefined() + }, + }) + } finally { + await restoreAuth() + } + }) + + test("aliased model (picker key != api.id) is matched by buildToolCapableSet on both ids", async () => { + // Regression: when a user registers an alias like + // `"my-claude-alias": { "id": "claude-opus-4-7", "tool_call": true }`, + // the picker map is keyed by "my-claude-alias" but the request body sends + // `model: "claude-opus-4-7"`. The allowlist must include BOTH so tools + // aren't silently stripped on the way out. + await setupOAuth() + try { + await using tmp = await tmpdir({ + config: { + provider: { + "snowflake-cortex": { + models: { + "my-claude-alias": { + id: "claude-opus-4-7", + name: "My Claude Alias", + limit: { context: 1000000, output: 128000 }, + tool_call: true, + }, + }, + }, + }, + } as any, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const toolCapable = buildToolCapableSet(providers["snowflake-cortex"].models) + // Both forms must be in the set. + expect(toolCapable.has("my-claude-alias")).toBe(true) + expect(toolCapable.has("claude-opus-4-7")).toBe(true) + + // And the transform must keep tools when the request uses the api.id form. + const input = JSON.stringify({ + model: "claude-opus-4-7", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "read_file" } }], + tool_choice: "auto", + }) + const { body } = transformSnowflakeBody(input, toolCapable) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeDefined() + expect(parsed.tool_choice).toBe("auto") + }, + }) + } finally { + await restoreAuth() + } + }) + + test("user can register a model not in the hardcoded list via altimate-code.json", async () => { + // Documents the option (2) escape hatch: when Snowflake adds a model + // before the CLI's hardcoded list catches up, users add it under + // provider['snowflake-cortex'].models and it merges into the picker. + await setupOAuth() + try { + await using tmp = await tmpdir({ + config: { + provider: { + "snowflake-cortex": { + models: { + "future-model-x": { + name: "Future Model X", + limit: { context: 200000, output: 32000 }, + tool_call: true, + }, + }, + }, + }, + } as any, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const m = providers["snowflake-cortex"].models["future-model-x"] + expect(m).toBeDefined() + expect(m.name).toBe("Future Model X") + expect(m.capabilities.toolcall).toBe(true) + expect(m.limit.context).toBe(200000) + // Built-in models still present alongside the config-added one. + expect(providers["snowflake-cortex"].models["claude-opus-4-7"]).toBeDefined() + }, + }) + } finally { + await restoreAuth() + } + }) + test("claude-3-5-sonnet output limit is 8192", async () => { await setupOAuth() try {