diff --git a/docs/docs/configure/models.md b/docs/docs/configure/models.md index e8cd310959..6b13c7ffcf 100644 --- a/docs/docs/configure/models.md +++ b/docs/docs/configure/models.md @@ -79,6 +79,7 @@ Models are referenced as `provider/model-name`: | Ollama | `ollama/llama3.1` | | OpenRouter | `openrouter/anthropic/claude-sonnet-4-6` | | Copilot | `copilot/gpt-4o` | +| Snowflake Cortex | `snowflake-cortex/claude-sonnet-4-6` | | Custom | `my-provider/my-model` | See [Providers](providers.md) for full provider configuration details. diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index a62e96d9e6..bf3700a118 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -176,6 +176,36 @@ Access 150+ models through a single API key. Uses your GitHub Copilot subscription. Authenticate with `altimate auth`. +## Snowflake Cortex + +```json +{ + "provider": { + "snowflake-cortex": {} + }, + "model": "snowflake-cortex/claude-sonnet-4-6" +} +``` + +Authenticate with `altimate auth snowflake-cortex` using a Programmatic Access Token (PAT). Enter credentials as `account-identifier::pat-token`. + +Create a PAT in Snowsight: **Admin > Security > Programmatic Access Tokens**. + +Billing flows through your Snowflake credits — no per-token costs. + +**Available models:** + +| 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 | + +!!! 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. + ## Custom / OpenAI-Compatible Any OpenAI-compatible endpoint can be used as a provider: diff --git a/docs/docs/reference/changelog.md b/docs/docs/reference/changelog.md index e48494e67a..7411303798 100644 --- a/docs/docs/reference/changelog.md +++ b/docs/docs/reference/changelog.md @@ -21,6 +21,16 @@ After upgrading, the TUI welcome banner shows what changed since your previous v --- +## [0.5.6] - 2026-03-21 + +### Added + +- Snowflake Cortex as a built-in AI provider with PAT authentication (#349) + - 26 models: Claude, OpenAI, Llama, Mistral, DeepSeek + - Tool calling support for Claude and OpenAI models + - Zero token cost — billing via Snowflake credits + - Cortex-specific request transforms (`max_completion_tokens`, tool stripping, synthetic stop) + ## [0.5.0] - 2026-03-18 ### Added diff --git a/packages/opencode/src/altimate/plugin/snowflake.ts b/packages/opencode/src/altimate/plugin/snowflake.ts new file mode 100644 index 0000000000..79a6bb112b --- /dev/null +++ b/packages/opencode/src/altimate/plugin/snowflake.ts @@ -0,0 +1,177 @@ +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", +]) + +/** Snowflake account identifiers contain only alphanumeric, hyphen, underscore, and dot characters. */ +export const VALID_ACCOUNT_RE = /^[a-zA-Z0-9._-]+$/ + +/** Parse a `account::token` PAT credential string. */ +export function parseSnowflakePAT(code: string): { account: string; token: string } | null { + const sep = code.indexOf("::") + if (sep === -1) return null + const account = code.substring(0, sep).trim() + const token = code.substring(sep + 2).trim() + if (!account || !token) return null + if (!VALID_ACCOUNT_RE.test(account)) return null + return { account, token } +} + +/** + * Transform a Snowflake Cortex request body string. + * Returns a Response to short-circuit the fetch (synthetic stop), or undefined to continue normally. + */ +export function transformSnowflakeBody(bodyText: string): { body: string; syntheticStop?: Response } { + const parsed = JSON.parse(bodyText) + + // Snowflake uses max_completion_tokens instead of max_tokens + if ("max_tokens" in parsed) { + parsed.max_completion_tokens = parsed.max_tokens + delete parsed.max_tokens + } + + // 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)) { + delete parsed.tools + delete parsed.tool_choice + if (Array.isArray(parsed.messages)) { + for (const msg of parsed.messages) { + if (msg.tool_calls) delete msg.tool_calls + } + parsed.messages = parsed.messages.filter((msg: { role: string }) => msg.role !== "tool") + } + } + + // Snowflake rejects requests where the last message is an assistant role. + // The AI SDK makes "continuation check" requests with the model's last response + // at the end. Stripping causes an infinite loop (same request → same response). + // Instead, short-circuit by returning a synthetic "stop" streaming response. + if (Array.isArray(parsed.messages)) { + const last = parsed.messages.at(-1) + if (parsed.stream !== false && last?.role === "assistant" && (!Array.isArray(last.tool_calls) || last.tool_calls.length === 0)) { + const encoder = new TextEncoder() + const chunks = [ + `data: {"id":"sf-done","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}\n\n`, + `data: {"id":"sf-done","object":"chat.completion.chunk","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}\n\n`, + `data: [DONE]\n\n`, + ] + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return { + body: JSON.stringify(parsed), + syntheticStop: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, + }), + } + } + } + + return { body: JSON.stringify(parsed) } +} + +export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise { + return { + auth: { + provider: "snowflake-cortex", + async loader(getAuth, provider) { + const auth = await getAuth() + if (auth.type !== "oauth") return {} + + // Zero costs (billed via Snowflake credits) + for (const model of Object.values(provider.models)) { + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } } + } + + return { + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + const currentAuth = await getAuth() + if (currentAuth.type !== "oauth") return fetch(requestInput, init) + + const headers = new Headers() + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((value, key) => headers.set(key, value)) + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + if (value !== undefined) headers.set(key, String(value)) + } + } else { + for (const [key, value] of Object.entries(init.headers)) { + if (value !== undefined) headers.set(key, String(value)) + } + } + } + + headers.set("authorization", `Bearer ${currentAuth.access}`) + headers.set("X-Snowflake-Authorization-Token-Type", "PROGRAMMATIC_ACCESS_TOKEN") + + let body = init?.body + if (body) { + try { + let text: string + if (typeof body === "string") { + text = body + } else if (body instanceof Uint8Array || body instanceof ArrayBuffer) { + text = new TextDecoder().decode(body) + } else { + // ReadableStream, Blob, FormData — pass through untransformed + text = "" + } + if (text) { + const result = transformSnowflakeBody(text) + if (result.syntheticStop) return result.syntheticStop + body = result.body + headers.delete("content-length") + } + } catch { + // JSON parse error — pass original body through untransformed + } + } + + return fetch(requestInput, { ...init, headers, body }) + }, + } + }, + methods: [ + { + label: "Snowflake PAT", + type: "oauth", + authorize: async () => ({ + url: "https://app.snowflake.com", + instructions: + "Enter your credentials as: ::\n e.g. myorg-myaccount::pat-token-here\n Create a PAT in Snowsight: Admin → Security → Programmatic Access Tokens", + method: "code" as const, + callback: async (code: string) => { + const parsed = parseSnowflakePAT(code) + if (!parsed) return { type: "failed" as const } + return { + type: "success" as const, + access: parsed.token, + refresh: "", + // PATs have variable TTLs (default 90 days); use conservative expiry + expires: Date.now() + 90 * 24 * 60 * 60 * 1000, + accountId: parsed.account, + } + }, + }), + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 72f1fee34d..2b61f0eb29 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -12,6 +12,9 @@ import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" +// altimate_change start — snowflake cortex plugin import +import { SnowflakeCortexAuthPlugin } from "../altimate/plugin/snowflake" +// altimate_change end export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -22,7 +25,9 @@ export namespace Plugin { // GitlabAuthPlugin uses a different version of @opencode-ai/plugin (from npm) // vs the workspace version, causing a type mismatch on internal HeyApiClient. // The types are structurally compatible at runtime. - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance] + // altimate_change start — snowflake cortex internal plugin + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin] + // altimate_change end const state = Instance.state(async () => { const client = createOpencodeClient({ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8f88b01b96..43d8a485d0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -46,6 +46,9 @@ import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" +// altimate_change start — snowflake cortex account validation +import { VALID_ACCOUNT_RE } from "../altimate/plugin/snowflake" +// altimate_change end const DEFAULT_CHUNK_TIMEOUT = 120_000 @@ -670,6 +673,20 @@ export namespace Provider { }, } }, + // altimate_change start — snowflake cortex provider loader + "snowflake-cortex": async () => { + const auth = await Auth.get("snowflake-cortex") + if (auth?.type !== "oauth") return { autoload: false } + const account = auth.accountId ?? Env.get("SNOWFLAKE_ACCOUNT") + if (!account || !VALID_ACCOUNT_RE.test(account)) return { autoload: false } + return { + autoload: true, + options: { + baseURL: `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`, + }, + } + }, + // altimate_change end } export const Model = z @@ -879,6 +896,83 @@ export namespace Provider { } } + // altimate_change start — snowflake cortex provider models + function makeSnowflakeModel( + id: string, + name: string, + limits: { context: number; output: number }, + caps?: { reasoning?: boolean; attachment?: boolean; toolcall?: boolean }, + ): Model { + const m: Model = { + id: ModelID.make(id), + providerID: ProviderID.snowflakeCortex, + api: { + id, + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name, + capabilities: { + temperature: true, + reasoning: caps?.reasoning ?? false, + attachment: caps?.attachment ?? false, + toolcall: caps?.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: limits.context, output: limits.output }, + status: "active" as const, + options: {}, + headers: {}, + release_date: "2024-01-01", + variants: {}, + } + m.variants = mapValues(ProviderTransform.variants(m), (v) => v) + return m + } + + database["snowflake-cortex"] = { + id: ProviderID.snowflakeCortex, + source: "custom", + name: "Snowflake Cortex", + env: [], + options: {}, + models: { + // Claude models — tool calling supported + "claude-sonnet-4-6": makeSnowflakeModel("claude-sonnet-4-6", "Claude Sonnet 4.6", { context: 200000, output: 64000 }), + "claude-opus-4-6": makeSnowflakeModel("claude-opus-4-6", "Claude Opus 4.6", { context: 200000, output: 32000 }), + "claude-sonnet-4-5": makeSnowflakeModel("claude-sonnet-4-5", "Claude Sonnet 4.5", { context: 200000, output: 64000 }), + "claude-opus-4-5": makeSnowflakeModel("claude-opus-4-5", "Claude Opus 4.5", { context: 200000, output: 32000 }), + "claude-haiku-4-5": makeSnowflakeModel("claude-haiku-4-5", "Claude Haiku 4.5", { context: 200000, output: 16000 }), + "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 }), + // 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": makeSnowflakeModel("openai-gpt-5", "OpenAI GPT-5", { context: 1047576, output: 32768 }), + "openai-gpt-5-mini": makeSnowflakeModel("openai-gpt-5-mini", "OpenAI GPT-5 Mini", { context: 1047576, output: 32768 }), + "openai-gpt-5-nano": makeSnowflakeModel("openai-gpt-5-nano", "OpenAI GPT-5 Nano", { 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", "Llama 4 Maverick", { context: 1048576, output: 4096 }, { toolcall: false }), + "snowflake-llama-3.3-70b": makeSnowflakeModel("snowflake-llama-3.3-70b", "Snowflake Llama 3.3 70B", { context: 128000, 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", { context: 128000, output: 4096 }, { 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", { context: 131000, output: 4096 }, { toolcall: false }), + "mistral-7b": makeSnowflakeModel("mistral-7b", "Mistral 7B", { context: 32000, output: 4096 }, { toolcall: false }), + // DeepSeek — no tool calling + "deepseek-r1": makeSnowflakeModel("deepseek-r1", "DeepSeek R1", { context: 64000, output: 32000 }, { reasoning: true, toolcall: false }), + }, + } + // altimate_change end + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 9eac235ceb..4e53acd6a6 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -23,6 +23,9 @@ export const ProviderID = providerIdSchema.pipe( azure: schema.makeUnsafe("azure"), openrouter: schema.makeUnsafe("openrouter"), mistral: schema.makeUnsafe("mistral"), + // altimate_change start — snowflake cortex provider ID + snowflakeCortex: schema.makeUnsafe("snowflake-cortex"), + // 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 new file mode 100644 index 0000000000..26c808d9af --- /dev/null +++ b/packages/opencode/test/altimate/cortex-snowflake-e2e.test.ts @@ -0,0 +1,442 @@ +/** + * Snowflake Cortex AI Provider E2E Tests + * + * Tests the Snowflake Cortex LLM inference endpoint via the provider's + * auth plugin and request transforms. + * + * Supports two auth methods (set ONE): + * + * # Option A — PAT (Programmatic Access Token): + * export SNOWFLAKE_CORTEX_ACCOUNT="" + * export SNOWFLAKE_CORTEX_PAT="" + * + * # Option B — Key-pair JWT (RSA private key): + * export SNOWFLAKE_CORTEX_ACCOUNT="" + * export SNOWFLAKE_CORTEX_USER="" + * export SNOWFLAKE_CORTEX_PRIVATE_KEY_PATH="/path/to/rsa_key.p8" + * + * Skips all tests if neither auth method is configured. + * + * Run: + * bun test test/altimate/cortex-snowflake-e2e.test.ts --timeout 120000 + */ + +import { describe, expect, test, beforeAll } from "bun:test" +import * as crypto from "crypto" +import * as fs from "fs" +import { + parseSnowflakePAT, + transformSnowflakeBody, + VALID_ACCOUNT_RE, +} from "../../src/altimate/plugin/snowflake" + +// --------------------------------------------------------------------------- +// Auth configuration +// --------------------------------------------------------------------------- + +const CORTEX_ACCOUNT = process.env.SNOWFLAKE_CORTEX_ACCOUNT +const CORTEX_PAT = process.env.SNOWFLAKE_CORTEX_PAT +const CORTEX_USER = process.env.SNOWFLAKE_CORTEX_USER +const CORTEX_KEY_PATH = process.env.SNOWFLAKE_CORTEX_PRIVATE_KEY_PATH + +const HAS_PAT = !!(CORTEX_ACCOUNT && CORTEX_PAT) +const HAS_KEYPAIR = !!(CORTEX_ACCOUNT && CORTEX_USER && CORTEX_KEY_PATH) +const HAS_CORTEX = HAS_PAT || HAS_KEYPAIR + +function cortexBaseURL(account: string): string { + return `https://${account}.snowflakecomputing.com/api/v2/cortex/v1` +} + +/** Generate a JWT for key-pair auth. */ +function generateJWT(account: string, user: string, keyPath: string): string { + const privateKey = fs.readFileSync(keyPath, "utf-8") + const qualifiedUser = `${account.toUpperCase()}.${user.toUpperCase()}` + + const pubKey = crypto.createPublicKey(crypto.createPrivateKey(privateKey)) + const pubKeyDer = pubKey.export({ type: "spki", format: "der" }) + const fingerprint = "SHA256:" + crypto.createHash("sha256").update(pubKeyDer).digest("base64") + + const now = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url") + const payload = Buffer.from( + JSON.stringify({ + iss: `${qualifiedUser}.${fingerprint}`, + sub: qualifiedUser, + iat: now, + exp: now + 3600, + }), + ).toString("base64url") + + const signature = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), privateKey).toString("base64url") + return `${header}.${payload}.${signature}` +} + +/** Build auth headers for whichever method is configured. */ +function authHeaders(): Record { + if (HAS_PAT) { + return { + Authorization: `Bearer ${CORTEX_PAT}`, + "X-Snowflake-Authorization-Token-Type": "PROGRAMMATIC_ACCESS_TOKEN", + } + } + const jwt = generateJWT(CORTEX_ACCOUNT!, CORTEX_USER!, CORTEX_KEY_PATH!) + return { + Authorization: `Bearer ${jwt}`, + "X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT", + } +} + +/** Make a raw Cortex chat completion request. */ +async function cortexChat(opts: { + model: string + messages: Array<{ role: string; content: string }> + stream?: boolean + max_tokens?: number + tools?: unknown[] +}): Promise { + const body: Record = { + model: opts.model, + messages: opts.messages, + max_completion_tokens: opts.max_tokens ?? 256, + } + if (opts.stream !== undefined) body.stream = opts.stream + if (opts.tools) { + body.tools = opts.tools + body.tool_choice = "auto" + } + + return fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders(), + }, + body: JSON.stringify(body), + }) +} + +// --------------------------------------------------------------------------- +// Cortex API E2E +// --------------------------------------------------------------------------- + +describe.skipIf(!HAS_CORTEX)("Snowflake Cortex E2E", () => { + // ------------------------------------------------------------------------- + // Authentication + // ------------------------------------------------------------------------- + describe("Authentication", () => { + test("valid credentials succeed", async () => { + const resp = await cortexChat({ + model: "claude-3-5-sonnet", + messages: [{ role: "user", content: "Reply with exactly: hello" }], + stream: false, + max_tokens: 32, + }) + expect(resp.status).toBe(200) + const json = await resp.json() + expect(json.choices).toBeDefined() + expect(json.choices.length).toBeGreaterThan(0) + expect(json.choices[0].message.content).toBeTruthy() + }, 30000) + + test("rejects invalid token", async () => { + const resp = await fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer invalid-token-xyz", + "X-Snowflake-Authorization-Token-Type": "PROGRAMMATIC_ACCESS_TOKEN", + }, + body: JSON.stringify({ + model: "claude-3-5-sonnet", + messages: [{ role: "user", content: "test" }], + max_completion_tokens: 16, + stream: false, + }), + }) + expect(resp.status).toBeGreaterThanOrEqual(400) + }, 15000) + }) + + // ------------------------------------------------------------------------- + // Non-streaming completions + // ------------------------------------------------------------------------- + describe("Non-Streaming Completions", () => { + test("returns valid JSON completion", async () => { + const resp = await cortexChat({ + model: "claude-3-5-sonnet", + messages: [{ role: "user", content: "What is 2+2? Reply with just the number." }], + stream: false, + max_tokens: 16, + }) + expect(resp.status).toBe(200) + const json = await resp.json() + expect(json.choices[0].message.role).toBe("assistant") + expect(json.choices[0].message.content).toContain("4") + }, 30000) + + test("uses max_completion_tokens (not max_tokens)", 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: "Say hello" }], + max_completion_tokens: 16, + stream: false, + }), + }) + expect(resp.status).toBe(200) + }, 30000) + + test("reports token usage", async () => { + const resp = await cortexChat({ + model: "claude-3-5-sonnet", + messages: [{ role: "user", content: "Say hi" }], + stream: false, + max_tokens: 16, + }) + const json = await resp.json() + expect(json.usage).toBeDefined() + expect(json.usage.prompt_tokens).toBeGreaterThan(0) + expect(json.usage.total_tokens).toBeGreaterThan(0) + }, 30000) + }) + + // ------------------------------------------------------------------------- + // Streaming completions + // ------------------------------------------------------------------------- + describe("Streaming Completions", () => { + test("returns SSE stream with chunked deltas", async () => { + const resp = await cortexChat({ + model: "claude-3-5-sonnet", + messages: [{ role: "user", content: "Count from 1 to 3." }], + stream: true, + max_tokens: 64, + }) + expect(resp.status).toBe(200) + expect(resp.headers.get("content-type")).toContain("text/event-stream") + + const text = await resp.text() + expect(text).toContain("data: ") + expect(text).toContain("data: [DONE]") + }, 30000) + }) + + // ------------------------------------------------------------------------- + // Model availability & response format + // ------------------------------------------------------------------------- + describe("Model Availability", () => { + // 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", + // OpenAI + "openai-gpt-4.1", "openai-gpt-5", "openai-gpt-5-mini", "openai-gpt-5-nano", + "openai-gpt-5-chat", + // Meta Llama + "llama4-maverick", "snowflake-llama-3.3-70b", "llama3.1-70b", "llama3.1-405b", "llama3.1-8b", + // Mistral + "mistral-large", "mistral-large2", "mistral-7b", + // DeepSeek + "deepseek-r1", + ] + + for (const model of allModels) { + test(`model ${model} responds or gracefully rejects`, async () => { + const resp = await cortexChat({ + model, + messages: [{ role: "user", content: "Reply with: ok" }], + stream: false, + max_tokens: 16, + }) + // 200 = available, 400 = not enabled/unknown, 403 = gated, 500 = unstable + expect([200, 400, 403, 500]).toContain(resp.status) + if (resp.status === 200) { + const json = await resp.json() + expect(json.choices).toBeDefined() + expect(json.choices[0].message.role).toBe("assistant") + // Some preview models (e.g., openai-gpt-5-*) return empty content + expect(json.choices[0].message.content).toBeDefined() + expect(json.usage).toBeDefined() + } + }, 30000) + } + }) + + // ------------------------------------------------------------------------- + // Tool calling — only Claude models support it on Cortex + // ------------------------------------------------------------------------- + describe("Tool Calling", () => { + const claudeModel = "claude-3-5-sonnet" + const nonClaudeModel = "mistral-large2" + + test(`${claudeModel} supports tool calls`, async () => { + const resp = await fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + model: claudeModel, + messages: [{ role: "user", content: "What is the weather in Paris?" }], + max_completion_tokens: 64, + stream: false, + tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }], + tool_choice: "auto", + }), + }) + // Accept 200 (tool call) or 400 (region-locked) + if (resp.status === 200) { + const json = await resp.json() + const tc = json.choices[0].message.tool_calls + expect(tc).toBeDefined() + expect(tc.length).toBeGreaterThan(0) + expect(tc[0].function.name).toBe("get_weather") + } + }, 30000) + + test(`${nonClaudeModel} rejects tool calls`, async () => { + const resp = await fetch(`${cortexBaseURL(CORTEX_ACCOUNT!)}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + model: nonClaudeModel, + messages: [{ role: "user", content: "What is the weather?" }], + max_completion_tokens: 32, + stream: false, + tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }], + }), + }) + // Non-Claude models reject tool calls with 400 + if (resp.status !== 200) { + expect(resp.status).toBe(400) + } + }, 30000) + }) + + // ------------------------------------------------------------------------- + // DeepSeek R1 reasoning format + // ------------------------------------------------------------------------- + describe("DeepSeek R1 Reasoning", () => { + test("deepseek-r1 returns tags in content", async () => { + const resp = await cortexChat({ + model: "deepseek-r1", + messages: [{ role: "user", content: "What is 2+2?" }], + stream: false, + max_tokens: 64, + }) + if (resp.status === 200) { + const json = await resp.json() + const content = json.choices[0].message.content + expect(content).toContain("") + } + }, 30000) + }) + + // ------------------------------------------------------------------------- + // Cortex rejects assistant-last messages + // ------------------------------------------------------------------------- + describe("Assistant-Last Message Handling", () => { + test("Cortex handles trailing assistant message", 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: "hello" }, + { role: "assistant", content: "I'm here" }, + ], + max_completion_tokens: 16, + stream: false, + }), + }) + // Cortex may accept (200) or reject (4xx) trailing assistant messages + // depending on the model and Cortex version. The synthetic stop in the + // provider handles both cases — it prevents the AI SDK's continuation + // loop from hitting Cortex repeatedly when the model echoes back. + expect(resp.status).toBeLessThan(500) + }, 15000) + }) + + // ------------------------------------------------------------------------- + // 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", + messages: [{ role: "user", content: "test" }], + max_tokens: 100, + stream: true, + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.max_completion_tokens).toBe(100) + expect(parsed.max_tokens).toBeUndefined() + }) + + test("tools stripped for llama model", () => { + const input = JSON.stringify({ + model: "llama3.3-70b", + messages: [{ role: "user", content: "test" }], + tools: [{ type: "function", function: { name: "read_file" } }], + tool_choice: "auto", + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + expect(parsed.tool_choice).toBeUndefined() + }) + + test("synthetic stop skipped for non-streaming", () => { + const input = JSON.stringify({ + model: "claude-3-5-sonnet", + stream: false, + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "response" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeUndefined() + }) + + test("synthetic stop triggered for streaming with trailing assistant", () => { + const input = JSON.stringify({ + model: "claude-3-5-sonnet", + stream: true, + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "response" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeDefined() + expect(syntheticStop!.status).toBe(200) + }) + }) + + // ------------------------------------------------------------------------- + // PAT parsing (if using PAT auth) + // ------------------------------------------------------------------------- + describe.skipIf(!HAS_PAT)("PAT Parsing with Real Credentials", () => { + test("parses real account::pat format", () => { + const account = CORTEX_ACCOUNT! + const pat = CORTEX_PAT! + const result = parseSnowflakePAT(`${account}::${pat}`) + expect(result).not.toBeNull() + expect(result!.account).toBe(account) + expect(result!.token).toBe(pat) + }) + + test("account passes validation regex", () => { + expect(VALID_ACCOUNT_RE.test(CORTEX_ACCOUNT!)).toBe(true) + }) + }) +}) diff --git a/packages/opencode/test/altimate/drivers-snowflake-e2e.test.ts b/packages/opencode/test/altimate/drivers-snowflake-e2e.test.ts index 696077cb4c..8d63f366dc 100644 --- a/packages/opencode/test/altimate/drivers-snowflake-e2e.test.ts +++ b/packages/opencode/test/altimate/drivers-snowflake-e2e.test.ts @@ -4,7 +4,7 @@ * Requires env vars (set one or more): * * # Password auth (primary): - * export ALTIMATE_CODE_CONN_SNOWFLAKE_TEST='{"type":"snowflake","account":"ejjkbko-fub20041","user":"juleszobi","password":"Ejungle9!","warehouse":"COMPUTE_WH","database":"TENANT_INFORMATICA_MIGRATION","schema":"public","role":"ACCOUNTADMIN"}' + * export ALTIMATE_CODE_CONN_SNOWFLAKE_TEST='{"type":"snowflake","account":"","user":"","password":"","warehouse":"","database":"","schema":"public","role":"ACCOUNTADMIN"}' * * # Key-pair auth (optional — requires RSA key setup in Snowflake): * export SNOWFLAKE_TEST_KEY_PATH="/path/to/rsa_key.p8" diff --git a/packages/opencode/test/provider/snowflake.test.ts b/packages/opencode/test/provider/snowflake.test.ts new file mode 100644 index 0000000000..3f16230fc2 --- /dev/null +++ b/packages/opencode/test/provider/snowflake.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { tmpdir } from "../fixture/fixture" +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" + +// --------------------------------------------------------------------------- +// parseSnowflakePAT +// --------------------------------------------------------------------------- + +describe("parseSnowflakePAT", () => { + test("parses valid account::token", () => { + const result = parseSnowflakePAT("myorg-myaccount::my-pat-token") + expect(result).toEqual({ account: "myorg-myaccount", token: "my-pat-token" }) + }) + + test("trims whitespace around account and token", () => { + const result = parseSnowflakePAT(" myorg-myaccount :: my-pat-token ") + expect(result).toEqual({ account: "myorg-myaccount", token: "my-pat-token" }) + }) + + test("returns null when separator is missing", () => { + expect(parseSnowflakePAT("myorg-myaccount;my-pat-token")).toBeNull() + expect(parseSnowflakePAT("myorg-myaccount:my-pat-token")).toBeNull() + expect(parseSnowflakePAT("myorg-myaccountmy-pat-token")).toBeNull() + }) + + test("returns null when account is empty", () => { + expect(parseSnowflakePAT("::my-pat-token")).toBeNull() + }) + + test("returns null when token is empty", () => { + expect(parseSnowflakePAT("myorg-myaccount::")).toBeNull() + }) + + test("returns null for empty string", () => { + expect(parseSnowflakePAT("")).toBeNull() + }) + + test("uses first :: as separator (token may contain ::)", () => { + const result = parseSnowflakePAT("myorg::token::with::colons") + expect(result).toEqual({ account: "myorg", token: "token::with::colons" }) + }) + + test("rejects account with slashes (URL injection)", () => { + expect(parseSnowflakePAT("evil/path::token")).toBeNull() + }) + + test("rejects account with query characters", () => { + expect(parseSnowflakePAT("evil?x=y::token")).toBeNull() + }) + + test("rejects account with hash fragment", () => { + expect(parseSnowflakePAT("evil#fragment::token")).toBeNull() + }) + + test("rejects account with spaces", () => { + expect(parseSnowflakePAT("evil account::token")).toBeNull() + }) + + test("rejects account with unicode characters", () => { + expect(parseSnowflakePAT("αλφα::token")).toBeNull() + }) + + test("accepts account with dots and underscores", () => { + const result = parseSnowflakePAT("my_org.account-1::token") + expect(result).toEqual({ account: "my_org.account-1", token: "token" }) + }) +}) + +// --------------------------------------------------------------------------- +// transformSnowflakeBody +// --------------------------------------------------------------------------- + +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 parsed = JSON.parse(body) + expect(parsed.max_completion_tokens).toBe(1000) + expect(parsed.max_tokens).toBeUndefined() + }) + + 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 parsed = JSON.parse(body) + expect(parsed.max_completion_tokens).toBe(1000) + expect(parsed.max_tokens).toBeUndefined() + }) + + test("strips tools for mistral-large2", () => { + const input = JSON.stringify({ + model: "mistral-large2", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read_file" } }], + tool_choice: "auto", + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + expect(parsed.tool_choice).toBeUndefined() + }) + + test("strips tools for llama3.3-70b", () => { + const input = JSON.stringify({ + model: "llama3.3-70b", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + }) + + test("strips tools for deepseek-r1", () => { + const input = JSON.stringify({ + model: "deepseek-r1", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + }) + + test("keeps tools for openai-gpt-4.1", () => { + const input = JSON.stringify({ + model: "openai-gpt-4.1", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeDefined() + expect(parsed.tools).toHaveLength(1) + }) + + test("keeps tools for claude-sonnet-4-6", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeDefined() + expect(parsed.tools).toHaveLength(1) + }) + + test("returns synthetic stop response when last message is assistant without tool_calls", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "I'm here!" }, + ], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeDefined() + expect(syntheticStop!.status).toBe(200) + expect(syntheticStop!.headers.get("content-type")).toBe("text/event-stream") + }) + + test("does NOT short-circuit when last message is assistant with tool_calls", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "", tool_calls: [{ id: "tc1", function: { name: "read_file" } }] }, + ], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeUndefined() + }) + + test("does NOT short-circuit when last message is user", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "test" }], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeUndefined() + }) + + test("does NOT short-circuit when stream is false", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + stream: false, + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "I'm here!" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeUndefined() + }) + + test("short-circuits when stream is true", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + stream: true, + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "I'm here!" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeDefined() + }) + + test("short-circuits when stream is not specified (defaults to streaming)", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "I'm here!" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeDefined() + }) + + test("triggers synthetic stop when tool_calls is empty array", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "done", tool_calls: [] }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeDefined() + }) + + test("removes orphaned tool_calls from messages for no-toolcall models", () => { + const input = JSON.stringify({ + model: "llama3.3-70b", + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "", tool_calls: [{ id: "tc1", function: { name: "read_file" } }] }, + { role: "tool", content: "file contents", tool_call_id: "tc1" }, + { role: "assistant", content: "here is the file" }, + ], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + // tool_calls should be removed from assistant messages + for (const msg of parsed.messages) { + expect(msg.tool_calls).toBeUndefined() + } + // tool role messages should be filtered out + expect(parsed.messages.every((m: { role: string }) => m.role !== "tool")).toBe(true) + }) + + test("throws on invalid JSON input", () => { + expect(() => transformSnowflakeBody("not-json")).toThrow() + }) + + test("synthetic stop SSE stream has correct format", async () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "done" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeDefined() + const text = await syntheticStop!.text() + // Should contain SSE data lines and [DONE] + expect(text).toContain("data: ") + expect(text).toContain('"finish_reason":"stop"') + expect(text).toContain("data: [DONE]") + // Should NOT contain usage block (avoids zero-token accounting issues) + expect(text).not.toContain('"usage"') + }) + + test("handles empty messages array without crashing", () => { + const input = JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeUndefined() + }) + + test("handles missing messages field", () => { + const input = JSON.stringify({ model: "claude-sonnet-4-6" }) + const { body } = transformSnowflakeBody(input) + expect(JSON.parse(body).model).toBe("claude-sonnet-4-6") + }) + + test("preserves max_completion_tokens when max_tokens is absent", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "test" }], + max_completion_tokens: 500, + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.max_completion_tokens).toBe(500) + expect(parsed.max_tokens).toBeUndefined() + }) + + test("handles both max_tokens and max_completion_tokens (max_tokens wins)", () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "test" }], + max_tokens: 100, + max_completion_tokens: 500, + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.max_completion_tokens).toBe(100) + expect(parsed.max_tokens).toBeUndefined() + }) + + test("strips tools for unknown model (not in TOOLCALL_MODELS allowlist)", () => { + const input = JSON.stringify({ + model: "some-future-model", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read_file" } }], + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tools).toBeUndefined() + }) + + test("strips tool_choice without tools for non-toolcall model", () => { + const input = JSON.stringify({ + model: "mistral-7b", + messages: [{ role: "user", content: "hello" }], + tool_choice: "auto", + }) + const { body } = transformSnowflakeBody(input) + const parsed = JSON.parse(body) + expect(parsed.tool_choice).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// Fetch interceptor (SnowflakeCortexAuthPlugin) +// --------------------------------------------------------------------------- + +describe("SnowflakeCortexAuthPlugin fetch interceptor", () => { + test("content-length header is deleted after body transformation", async () => { + // Simulate what the fetch wrapper does: copy headers, transform body, delete content-length + const originalBody = JSON.stringify({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "test" }], + max_tokens: 1000, + }) + const headers = new Headers({ + "content-type": "application/json", + "content-length": String(originalBody.length), + }) + + // Transform body (same logic as the fetch wrapper) + const result = transformSnowflakeBody(originalBody) + const newBody = result.body + + // Body changed (max_tokens → max_completion_tokens), so lengths differ + expect(newBody.length).not.toBe(originalBody.length) + + // The fetch wrapper should delete content-length after transform + headers.delete("content-length") + expect(headers.has("content-length")).toBe(false) + }) + + test("synthetic stop returns valid SSE Response object", async () => { + const input = JSON.stringify({ + model: "claude-sonnet-4-6", + stream: true, + messages: [ + { role: "user", content: "test" }, + { role: "assistant", content: "response" }, + ], + }) + const { syntheticStop } = transformSnowflakeBody(input) + expect(syntheticStop).toBeInstanceOf(Response) + expect(syntheticStop!.status).toBe(200) + expect(syntheticStop!.headers.get("content-type")).toBe("text/event-stream") + expect(syntheticStop!.headers.get("cache-control")).toBe("no-cache") + + // Body should be a readable stream + const text = await syntheticStop!.text() + const lines = text.split("\n").filter((l: string) => l.startsWith("data: ")) + expect(lines.length).toBe(3) // delta, stop, [DONE] + }) +}) + +// --------------------------------------------------------------------------- +// Provider registration +// --------------------------------------------------------------------------- + +describe("snowflake-cortex provider", () => { + // Save and restore any real stored credentials to keep tests hermetic + let savedAuth: Awaited> + const setupOAuth = async (account = "myorg-myaccount") => { + savedAuth = await Auth.get("snowflake-cortex") + await Auth.set("snowflake-cortex", { + type: "oauth", + access: "test-pat-token", + refresh: "", + expires: Date.now() + 90 * 24 * 60 * 60 * 1000, + accountId: account, + }) + } + const restoreAuth = async () => { + if (savedAuth) { + await Auth.set("snowflake-cortex", savedAuth) + } else { + await Auth.remove("snowflake-cortex") + } + } + + test("loads when oauth auth with accountId is set", async () => { + await setupOAuth() + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["snowflake-cortex"]).toBeDefined() + expect(providers["snowflake-cortex"].options.baseURL).toBe( + "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1", + ) + }, + }) + } finally { + await restoreAuth() + } + }) + + test("does not load without oauth auth", async () => { + savedAuth = await Auth.get("snowflake-cortex") + if (savedAuth) await Auth.remove("snowflake-cortex") + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.remove("SNOWFLAKE_ACCOUNT") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["snowflake-cortex"]).toBeUndefined() + }, + }) + } finally { + await restoreAuth() + } + }) + + test("does not load with only SNOWFLAKE_ACCOUNT env (no oauth)", async () => { + savedAuth = await Auth.get("snowflake-cortex") + if (savedAuth) await Auth.remove("snowflake-cortex") + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("SNOWFLAKE_ACCOUNT", "myorg-myaccount") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["snowflake-cortex"]).toBeUndefined() + }, + }) + } finally { + await restoreAuth() + } + }) + + test("Claude and OpenAI models have toolcall: true", async () => { + await setupOAuth() + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const models = providers["snowflake-cortex"].models + // Claude + 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) + }, + }) + } finally { + await restoreAuth() + } + }) + + test("Llama, Mistral, and DeepSeek models have toolcall: false", async () => { + await setupOAuth() + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + 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.1-70b"].capabilities.toolcall).toBe(false) + expect(models["deepseek-r1"].capabilities.toolcall).toBe(false) + expect(models["llama4-maverick"].capabilities.toolcall).toBe(false) + }, + }) + } finally { + await restoreAuth() + } + }) + + test("all models have zero cost", async () => { + await setupOAuth() + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + for (const model of Object.values(providers["snowflake-cortex"].models)) { + expect(model.cost.input).toBe(0) + expect(model.cost.output).toBe(0) + } + }, + }) + } finally { + await restoreAuth() + } + }) + + test("env array is empty (auth-only provider)", async () => { + await setupOAuth() + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["snowflake-cortex"].env).toEqual([]) + }, + }) + } finally { + await restoreAuth() + } + }) + + test("claude-3-5-sonnet output limit is 8192", async () => { + await setupOAuth() + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["snowflake-cortex"].models["claude-3-5-sonnet"].limit.output).toBe(8192) + }, + }) + } finally { + await restoreAuth() + } + }) +})