Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/configure/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 30 additions & 0 deletions docs/docs/configure/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions docs/docs/reference/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions packages/opencode/src/altimate/plugin/snowflake.ts
Original file line number Diff line number Diff line change
@@ -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<Hooks> {
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: <account-identifier>::<PAT-token>\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,
}
},
}),
},
],
},
}
}
7 changes: 6 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand All @@ -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({
Expand Down
94 changes: 94 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`,
},
Comment on lines +677 to +686
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't expose SNOWFLAKE_ACCOUNT as a provider credential.

provider.env is consumed as auth input in state() (Lines 1053-1058), and getSDK() later copies a single env value into apiKey on Line 1248. With the current loader + metadata, SNOWFLAKE_ACCOUNT=myorg makes the provider look configured even when no PAT exists, so requests go out as Authorization: Bearer myorg and never get the Snowflake PAT header from packages/opencode/src/altimate/plugin/snowflake.ts Lines 91-139. The env fallback also bypasses the account validation in packages/opencode/src/altimate/plugin/snowflake.ts Lines 10-17, so malformed values are interpolated straight into baseURL.

Suggested direction
     "snowflake-cortex": async () => {
       const auth = await Auth.get("snowflake-cortex")
-      const account = iife(() => {
-        if (auth?.type === "oauth" && auth.accountId) return auth.accountId
-        return Env.get("SNOWFLAKE_ACCOUNT")
-      })
-      if (!account) return { autoload: false }
+      if (auth?.type !== "oauth") return { autoload: false }
+      const account = auth.accountId ?? Env.get("SNOWFLAKE_ACCOUNT")
+      if (!account || !/^[a-zA-Z0-9._-]+$/.test(account)) return { autoload: false }
       return {
         autoload: true,
         options: {
           baseURL: `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`,
@@
     database["snowflake-cortex"] = {
       id: ProviderID.snowflakeCortex,
       source: "custom",
       name: "Snowflake Cortex",
-      env: ["SNOWFLAKE_ACCOUNT"],
+      env: [],

Also applies to: 935-939

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/provider/provider.ts` around lines 674 - 685, The
provider should not treat SNOWFLAKE_ACCOUNT env as a credential fallback: remove
the Env.get("SNOWFLAKE_ACCOUNT") fallback in the "snowflake-cortex" provider
construction so account is derived only from Auth.get("snowflake-cortex")
(auth.type === "oauth" && auth.accountId) and return { autoload: false } when
absent; update provider.env/metadata and any loader logic so state() and
getSDK() no longer accept or copy SNOWFLAKE_ACCOUNT into apiKey (ensure getSDK()
pulls the PAT from the proper auth object), and rely on the existing account
validation in the snowflake plugin rather than interpolating env values into
baseURL.

}
},
// altimate_change end
}

export const Model = z
Expand Down Expand Up @@ -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<Info>) {
const existing = providers[providerID]
if (existing) {
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/provider/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})),
)

Expand Down
Loading
Loading