-
Notifications
You must be signed in to change notification settings - Fork 16
feat: add Snowflake Cortex as an AI provider #349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
d03ba0d
feat: add Snowflake Cortex as an AI provider
mdesmet e498403
fix: harden Snowflake Cortex provider with `altimate_change` markers …
anandgupta42 7167ef0
fix: require oauth auth for snowflake-cortex, don't expose account as…
anandgupta42 bba5356
fix: address consensus code review findings
anandgupta42 512915a
test: add Cortex E2E tests and sanitize hardcoded credentials
anandgupta42 bdf23f7
fix: update Cortex models from E2E testing against real Snowflake API
anandgupta42 a358d3d
feat: add OpenAI and additional Claude models from Snowflake Cortex docs
anandgupta42 9a7cef1
fix: verify model availability via live API, remove broken models
anandgupta42 96eb5a5
docs+test: pre-release — docs, test gaps, and full model validation
anandgupta42 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| } | ||
| }, | ||
| }), | ||
| }, | ||
| ], | ||
| }, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't expose
SNOWFLAKE_ACCOUNTas a provider credential.provider.envis consumed as auth input instate()(Lines 1053-1058), andgetSDK()later copies a single env value intoapiKeyon Line 1248. With the current loader + metadata,SNOWFLAKE_ACCOUNT=myorgmakes the provider look configured even when no PAT exists, so requests go out asAuthorization: Bearer myorgand never get the Snowflake PAT header frompackages/opencode/src/altimate/plugin/snowflake.tsLines 91-139. The env fallback also bypasses the account validation inpackages/opencode/src/altimate/plugin/snowflake.tsLines 10-17, so malformed values are interpolated straight intobaseURL.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