diff --git a/packages/core/src/plugin/provider/index.ts b/packages/core/src/plugin/provider/index.ts index fd02d322a1f9..0ebbd50f617e 100644 --- a/packages/core/src/plugin/provider/index.ts +++ b/packages/core/src/plugin/provider/index.ts @@ -19,6 +19,7 @@ import { LLMGatewayPlugin } from "./llmgateway" import { MistralPlugin } from "./mistral" import { NvidiaPlugin } from "./nvidia" import { OpenAIPlugin } from "./openai" +import { SnowflakeCortexPlugin } from "./snowflake-cortex" import { OpenAICompatiblePlugin } from "./openai-compatible" import { OpencodePlugin } from "./opencode" import { OpenRouterPlugin } from "./openrouter" @@ -53,6 +54,7 @@ export const ProviderPlugins = [ MistralPlugin, NvidiaPlugin, OpencodePlugin, + SnowflakeCortexPlugin, OpenAICompatiblePlugin, OpenAIPlugin, OpenRouterPlugin, diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts new file mode 100644 index 000000000000..3493b976bda8 --- /dev/null +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -0,0 +1,81 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise + +// Exported for testing: intercepts Cortex-specific request/response quirks. +export function cortexFetch(upstream: FetchLike = fetch) { + return async (url: string | URL | Request, init?: RequestInit): Promise => { + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if ("max_tokens" in body) { + body.max_completion_tokens = body.max_tokens + delete body.max_tokens + init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + + const response = await upstream(url, init) + + // Cortex returns 400 "conversation complete" as a normal stop condition + if (!response.ok && response.status === 400) { + try { + const errorData = (await response.clone().json()) as Record + if (String(errorData.message || errorData.error || "").toLowerCase().includes("conversation complete")) { + return new Response( + JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }), + { status: 200, headers: new Headers({ "content-type": "application/json" }) }, + ) + } + } catch {} + } + + // Cortex returns role:"" in streaming deltas; the AI SDK schema requires "assistant" + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + ctrl.enqueue(encoder.encode(decoder.decode(value, { stream: true }).replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + + return response + } +} + +export const SnowflakeCortexPlugin = PluginV2.define({ + id: PluginV2.ID.make("snowflake-cortex"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return + const pat = + process.env.SNOWFLAKE_CORTEX_PAT ?? + (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined) + const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined + if (evt.options.includeUsage !== false) evt.options.includeUsage = true + const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + evt.sdk = mod.createOpenAICompatible({ + ...evt.options, + ...(pat ? { apiKey: pat } : {}), + fetch: cortexFetch(upstream) as typeof fetch, + } as any) + }), + } + }), +}) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts new file mode 100644 index 000000000000..cec25dbf0562 --- /dev/null +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it as bun_it } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { expectPluginRegistered, it, model, withEnv } from "./provider-helper" + +describe("SnowflakeCortexPlugin", () => { + it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => + Effect.sync(() => { + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "snowflake-cortex", + ) + const ids = ProviderPlugins.map((p) => p.id as string) + expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) + }), + ) + + it.effect("ignores non-snowflake-cortex providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(SnowflakeCortexPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("creates SDK for snowflake-cortex using SNOWFLAKE_CORTEX_PAT env var", () => + withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(SnowflakeCortexPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("snowflake-cortex", "claude-sonnet-4-6"), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ), + ) + + it.effect("falls back to options.apiKey when SNOWFLAKE_CORTEX_PAT env var is absent", () => + withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(SnowflakeCortexPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("snowflake-cortex", "claude-sonnet-4-6"), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + apiKey: "options-pat", + }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ), + ) + + it.effect("sets includeUsage on the SDK options", () => + withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const captured: Record[] = [] + yield* plugin.add(SnowflakeCortexPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + captured.push({ ...evt.options }) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("snowflake-cortex", "claude-sonnet-4-6"), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }, + {}, + ) + expect(captured[0]?.includeUsage).toBe(true) + }), + ), + ) +}) + +type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise + +describe("cortexFetch", () => { + bun_it("rewrites max_tokens to max_completion_tokens", async () => { + const captured: RequestInit[] = [] + const upstream: FetchLike = async (_url, init) => { + captured.push(init ?? {}) + return new Response("{}", { status: 200 }) + } + await cortexFetch(upstream)("https://test", { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1024 }), + }) + const body = JSON.parse(captured[0].body as string) + expect(body.max_completion_tokens).toBe(1024) + expect(body.max_tokens).toBeUndefined() + }) + + bun_it("preserves body when max_tokens is absent", async () => { + const captured: RequestInit[] = [] + const upstream: FetchLike = async (_url, init) => { + captured.push(init ?? {}) + return new Response("{}", { status: 200 }) + } + const original = JSON.stringify({ model: "claude-sonnet-4-6", temperature: 0.7 }) + await cortexFetch(upstream)("https://test", { method: "POST", body: original }) + expect(captured[0].body).toBe(original) + }) + + bun_it("treats 400 'conversation complete' as a stop response", async () => { + const upstream: FetchLike = async () => + new Response(JSON.stringify({ message: "Conversation complete" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + const response = await cortexFetch(upstream)("https://test", {}) + expect(response.status).toBe(200) + const data = (await response.json()) as { choices: { finish_reason: string }[] } + expect(data.choices[0].finish_reason).toBe("stop") + }) + + bun_it("passes through other 400 errors unchanged", async () => { + const upstream: FetchLike = async () => + new Response(JSON.stringify({ message: "Invalid model" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + const response = await cortexFetch(upstream)("https://test", {}) + expect(response.status).toBe(400) + }) + + bun_it("passes through non-400 errors unchanged", async () => { + const upstream: FetchLike = async () => new Response("Unauthorized", { status: 401 }) + const response = await cortexFetch(upstream)("https://test", {}) + expect(response.status).toBe(401) + }) + + bun_it("handles invalid JSON body gracefully without throwing", async () => { + const captured: RequestInit[] = [] + const upstream: FetchLike = async (_url, init) => { + captured.push(init ?? {}) + return new Response("{}", { status: 200 }) + } + const invalidBody = "{ not json }" + await cortexFetch(upstream)("https://test", { method: "POST", body: invalidBody }) + expect(captured[0].body).toBe(invalidBody) + }) + + bun_it("rewrites role:'' to role:'assistant' in streaming SSE chunks", async () => { + const chunk = `data: {"choices":[{"delta":{"role":"","content":"Hi"},"index":0}]}\n\n` + const upstream: FetchLike = async () => + new Response(new ReadableStream({ start: (ctrl) => { ctrl.enqueue(new TextEncoder().encode(chunk)); ctrl.close() } }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + const response = await cortexFetch(upstream)("https://test", {}) + const text = await response.text() + expect(text).toContain('"role":"assistant"') + expect(text).not.toContain('"role":""') + }) +}) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 560781ef0e43..639d868cd8dd 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -474,6 +474,25 @@ export const ProvidersLoginCommand = effectCmd({ ) } + if (provider === "snowflake-cortex") { + const account = yield* promptValue( + yield* Prompt.text({ + message: "Snowflake Account Identifier", + placeholder: "xy12345.us-east-1", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }), + ) + const pat = yield* promptValue( + yield* Prompt.password({ + message: "Programmatic Access Token (PAT)", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }), + ) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: pat, metadata: { account } })) + yield* Prompt.outro("Done") + return + } + const key = yield* Prompt.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 49c582e0a99e..3ffab785c2a2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -858,6 +858,94 @@ function custom(dep: CustomDep): Record { }, }, }), + "snowflake-cortex": Effect.fnUntraced(function* (input: Info) { + const env = yield* dep.env() + const auth = yield* dep.auth(input.id) + + const account = + env["SNOWFLAKE_ACCOUNT"] ?? + (auth?.type === "api" ? auth.metadata?.account : undefined) ?? + input.options?.account + + const pat = + env["SNOWFLAKE_CORTEX_PAT"] ?? + (auth?.type === "api" ? auth.key : undefined) ?? + input.options?.apiKey + + if (!account || !pat) { + const missing = [!account && "SNOWFLAKE_ACCOUNT", !pat && "SNOWFLAKE_CORTEX_PAT"].filter(Boolean).join(", ") + return { + autoload: false, + async getModel() { + throw new Error( + `Snowflake Cortex: missing credentials (${missing}). Set via env var, opencode auth, or provider options.`, + ) + }, + } + } + + const baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1` + + return { + autoload: input.source === "config", + options: { + baseURL, + apiKey: pat, + fetch: async (url: RequestInfo | URL, init?: RequestInit) => { + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if ("max_tokens" in body) { + body.max_completion_tokens = body.max_tokens + delete body.max_tokens + init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + + const response = await fetch(url, init) + + // Cortex returns 400 "conversation complete" as a normal stop condition + if (!response.ok && response.status === 400) { + try { + const errorData = await response.clone().json() + const errorMessage = String(errorData.message || errorData.error || "") + if (errorMessage.toLowerCase().includes("conversation complete")) { + return new Response( + JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }), + { status: 200, headers: new Headers({ "content-type": "application/json" }) }, + ) + } + } catch {} + } + + // Cortex returns role:"" in streaming deltas; the AI SDK schema requires "assistant" + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + const text = decoder.decode(value, { stream: true }) + ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + + return response + }, + }, + } + }), } } diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index f5c2160daaf4..628e2244e03c 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1940,6 +1940,65 @@ To use [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-ap --- +### Snowflake Cortex + +[Snowflake Cortex](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api) gives you access to frontier models (Claude, OpenAI GPT-5, and more) via an OpenAI-compatible API. All inference runs within the Snowflake perimeter and is billed in Snowflake credits. For per-model rates, see the [Snowflake Service Consumption Table](https://www.snowflake.com/legal-files/CreditConsumptionTable.pdf). + +Don't have a Snowflake account? [Sign up for a free trial](https://signup.snowflake.com/?utm_source=opencode&utm_medium=docs&utm_campaign=cortex-provider). + +opencode's core workflow for coding, editing files, and running commands relies on tool calling. Only the Claude and OpenAI families within Snowflake Cortex support this. The provider is limited to those families to support the core workflow. + +1. Generate a [Programmatic Access Token (PAT)](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) in your Snowflake account. + +2. Run the `/connect` command and search for **Snowflake Cortex**. + + ```txt + /connect + ``` + +3. Enter your [account identifier](https://docs.snowflake.com/en/user-guide/admin-account-identifier) when prompted (e.g. `myorg-myaccount` or `xy12345.us-east-1`). + + ```txt + ┌ Snowflake Account Identifier + │ + │ + └ enter + ``` + +4. Enter your **PAT**. + + ```txt + ┌ Programmatic Access Token (PAT) + │ + │ + └ enter + ``` + +5. Run the `/models` command to select a model. + + ```txt + /models + ``` + +Alternatively, set environment variables before starting opencode: + +```bash +export SNOWFLAKE_ACCOUNT=myorg-myaccount +export SNOWFLAKE_CORTEX_PAT=your-pat +``` + +The model catalog is provided automatically. A minimal `opencode.json` is all that's needed: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "snowflake-cortex/claude-sonnet-4-6", + "small_model": "snowflake-cortex/claude-haiku-4-5" +} +``` + +--- + ### Together AI 1. Head over to the [Together AI console](https://api.together.ai), create an account, and click **Add Key**.