feat(opencode): add external browser OAuth for snowflake cortex provider (#31700)
Co-authored-by: santiago.gonzalezcarvajcentenera <santiago.gonzalezcarvajcentenera@colaborador.elcorteingles.es> Co-authored-by: David Fierro <14184197+davidfierro@users.noreply.github.com> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com>
This commit is contained in:
parent
fcca731a96
commit
3f174531b9
8 changed files with 975 additions and 92 deletions
|
|
@ -477,25 +477,6 @@ 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"),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
|
|||
import { AzureAuthPlugin } from "./azure"
|
||||
import { DigitalOceanAuthPlugin } from "./digitalocean"
|
||||
import { XaiAuthPlugin } from "./xai"
|
||||
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
|
@ -75,6 +76,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
|||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
SnowflakeCortexAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
523
packages/opencode/src/plugin/snowflake-cortex.ts
Normal file
523
packages/opencode/src/plugin/snowflake-cortex.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
const OAUTH_CLIENT_ID = "LOCAL_APPLICATION"
|
||||
const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||
const OAUTH_CALLBACK_PATH = "/"
|
||||
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
interface PendingOAuth {
|
||||
account: string
|
||||
state: string
|
||||
pkce: PkceCodes
|
||||
resolve: (tokens: TokenResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
let oauthServerPort: number | undefined
|
||||
|
||||
function normalizeAccount(input: string) {
|
||||
return input.trim().replace(/^https?:\/\//, "").replace(/\.snowflakecomputing\.com\/?$/, "").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function generateRandomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||
const binary = String.fromCharCode(...new Uint8Array(buffer))
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(64)
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
|
||||
return {
|
||||
verifier,
|
||||
challenge: base64UrlEncode(hash),
|
||||
}
|
||||
}
|
||||
|
||||
function callbackUrl() {
|
||||
if (!oauthServerPort) throw new Error("Snowflake OAuth callback server is not running")
|
||||
return `http://${OAUTH_CALLBACK_HOST}:${oauthServerPort}${OAUTH_CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
export function oauthScope(role: string | undefined) {
|
||||
if (!role) return "refresh_token"
|
||||
return /^[-_A-Za-z0-9]+$/.test(role)
|
||||
? `refresh_token session:role:${role}`
|
||||
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
}
|
||||
|
||||
function authBasicHeader() {
|
||||
return `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_ID}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(account: string, role: string | undefined, state: string, pkce: PkceCodes) {
|
||||
const scope = oauthScope(role)
|
||||
const params = new URLSearchParams({
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: callbackUrl(),
|
||||
scope,
|
||||
state,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})
|
||||
return `https://${account}.snowflakecomputing.com/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(account: string, code: string, pkce: PkceCodes) {
|
||||
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
Authorization: authBasicHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: callbackUrl(),
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`Snowflake token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
|
||||
const token = (await response.json()) as TokenResponse
|
||||
if (!token.access_token) throw new Error("Snowflake token response did not include access_token")
|
||||
if (!token.refresh_token) {
|
||||
throw new Error(
|
||||
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
|
||||
)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
async function refreshAccessToken(account: string, refreshToken: string) {
|
||||
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
Authorization: authBasicHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`Snowflake token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
|
||||
const token = (await response.json()) as TokenResponse
|
||||
if (!token.access_token) throw new Error("Snowflake refresh response did not include access_token")
|
||||
return token
|
||||
}
|
||||
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
|
||||
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
|
||||
<div style="text-align:center; max-width:36rem; padding:2rem;">
|
||||
<h1 style="color:#7ee787;">Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 1500)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const htmlError = (message: string) => `<!doctype html>
|
||||
<html>
|
||||
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
|
||||
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
|
||||
<div style="text-align:center; max-width:48rem; padding:2rem;">
|
||||
<h1 style="color:#ff7b72;">Authorization Failed</h1>
|
||||
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function startOAuthServer() {
|
||||
if (oauthServer) return
|
||||
|
||||
oauthServer = createServer((req, res) => {
|
||||
const host = req.headers.host || `${OAUTH_CALLBACK_HOST}:${oauthServerPort ?? 0}`
|
||||
const url = new URL(req.url || "/", `http://${host}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const state = url.searchParams.get("state")
|
||||
const code = url.searchParams.get("code")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
// CSRF guard: validate state before processing any callback
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const message = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(message))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(htmlError(message))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
if (error) {
|
||||
const message = errorDescription || error
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(htmlError(message))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const message = "Missing authorization code"
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(htmlError(message))
|
||||
return
|
||||
}
|
||||
|
||||
exchangeCodeForToken(current.account, code, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(0, OAUTH_CALLBACK_HOST, () => {
|
||||
const address = oauthServer!.address()
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Unable to resolve Snowflake OAuth callback port"))
|
||||
return
|
||||
}
|
||||
oauthServerPort = address.port
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (!oauthServer) return
|
||||
oauthServer.close()
|
||||
oauthServer = undefined
|
||||
oauthServerPort = undefined
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(account: string, pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth.reject(new Error("Superseded by a newer Snowflake authorize request"))
|
||||
pendingOAuth = undefined
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!pendingOAuth) return
|
||||
pendingOAuth = undefined
|
||||
stopOAuthServer()
|
||||
reject(new Error("Snowflake OAuth callback timeout - authorization took too long"))
|
||||
}, OAUTH_TIMEOUT_MS)
|
||||
|
||||
pendingOAuth = {
|
||||
account,
|
||||
state,
|
||||
pkce,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "account",
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
validate: (value: string) => (value && value.trim().length > 0 ? undefined : "Required"),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "role",
|
||||
message: "Snowflake Role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "snowflake-cortex",
|
||||
async loader(getAuth, _provider) {
|
||||
let auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
let refreshPromise:
|
||||
| Promise<{
|
||||
access: string
|
||||
refresh: string
|
||||
expires: number
|
||||
}>
|
||||
| undefined
|
||||
|
||||
const oauth = auth as typeof auth & { refresh: string; access: string; expires: number; accountId?: string }
|
||||
|
||||
if (oauth.accountId && oauth.refresh && oauth.expires && oauth.expires <= Date.now()) {
|
||||
try {
|
||||
const tokens = await refreshAccessToken(oauth.accountId, oauth.refresh)
|
||||
const refreshedRefresh = tokens.refresh_token || oauth.refresh
|
||||
const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000
|
||||
await _input.client.auth
|
||||
.set({
|
||||
path: { id: "snowflake-cortex" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
...(oauth.accountId && { accountId: oauth.accountId }),
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
let currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
let currentOauth = currentAuth as typeof currentAuth & {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
if (!currentOauth.accountId) throw new Error("Snowflake OAuth auth is missing accountId")
|
||||
const accountId = currentOauth.accountId
|
||||
|
||||
const refresh = async () => {
|
||||
if (!refreshPromise) {
|
||||
const refreshToken = currentOauth.refresh
|
||||
refreshPromise = refreshAccessToken(accountId, refreshToken)
|
||||
.then(async (tokens) => {
|
||||
const refreshedRefresh = tokens.refresh_token || refreshToken
|
||||
const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000
|
||||
await _input.client.auth
|
||||
.set({
|
||||
path: { id: "snowflake-cortex" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
...(accountId && { accountId }),
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return {
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const refreshed = await refreshPromise
|
||||
currentOauth = { ...currentOauth, ...refreshed }
|
||||
}
|
||||
|
||||
const prepareRequest = () => {
|
||||
const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
|
||||
if (init?.headers) {
|
||||
const entries =
|
||||
init.headers instanceof Headers
|
||||
? init.headers.entries()
|
||||
: Array.isArray(init.headers)
|
||||
? init.headers
|
||||
: Object.entries(init.headers as Record<string, string | undefined>)
|
||||
for (const [key, value] of entries) {
|
||||
if (value !== undefined) headers.set(key, String(value))
|
||||
}
|
||||
}
|
||||
headers.set("authorization", `Bearer ${currentOauth.access}`)
|
||||
headers.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
|
||||
let body = init?.body
|
||||
if (body && typeof body === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
if ("max_tokens" in parsed) {
|
||||
parsed.max_completion_tokens = parsed.max_tokens
|
||||
delete parsed.max_tokens
|
||||
body = JSON.stringify(parsed)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { ...init, headers, body }
|
||||
}
|
||||
|
||||
const transformResponse = async (response: Response) => {
|
||||
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 {}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const expiresSoon =
|
||||
!currentOauth.expires || !currentOauth.access || currentOauth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS
|
||||
|
||||
if (expiresSoon) await refresh()
|
||||
|
||||
const response = await fetch(requestInput, prepareRequest())
|
||||
|
||||
if (response.status === 401) {
|
||||
await refresh()
|
||||
return transformResponse(await fetch(requestInput, prepareRequest()))
|
||||
}
|
||||
|
||||
return transformResponse(response)
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
prompts,
|
||||
async authorize(inputs = {}) {
|
||||
const account = normalizeAccount(inputs.account || "")
|
||||
if (!account) throw new Error("Snowflake account is required")
|
||||
|
||||
await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateRandomString(64)
|
||||
const role = (inputs.role || "").trim() || undefined
|
||||
const url = buildAuthorizeUrl(account, role, state, pkce)
|
||||
const callbackPromise = waitForOAuthCallback(account, pkce, state)
|
||||
await open(url).catch(() => undefined)
|
||||
|
||||
return {
|
||||
url,
|
||||
instructions:
|
||||
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token!,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
|
||||
accountId: account,
|
||||
}
|
||||
} catch {
|
||||
return { type: "failed" as const }
|
||||
} finally {
|
||||
stopOAuthServer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
label: "Paste PAT or bearer token manually",
|
||||
prompts: prompts.filter((item) => item.key === "account"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -853,17 +853,23 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||
const account =
|
||||
env["SNOWFLAKE_ACCOUNT"] ??
|
||||
(auth?.type === "api" ? auth.metadata?.account : undefined) ??
|
||||
(auth?.type === "oauth" ? auth.accountId : undefined) ??
|
||||
input.options?.account
|
||||
|
||||
const pat = env["SNOWFLAKE_CORTEX_PAT"] ?? (auth?.type === "api" ? auth.key : undefined) ?? input.options?.apiKey
|
||||
const envToken = env["SNOWFLAKE_CORTEX_TOKEN"] ?? env["SNOWFLAKE_CORTEX_PAT"]
|
||||
const apiKeyToken = auth?.type === "api" ? auth.key : undefined
|
||||
const oauthToken = auth?.type === "oauth" ? auth.access : undefined
|
||||
const configToken = input.options?.token ?? input.options?.apiKey
|
||||
|
||||
if (!account || !pat) {
|
||||
const missing = [!account && "SNOWFLAKE_ACCOUNT", !pat && "SNOWFLAKE_CORTEX_PAT"].filter(Boolean).join(", ")
|
||||
const token = envToken ?? apiKeyToken ?? oauthToken ?? configToken
|
||||
|
||||
if (!account || !token) {
|
||||
const missing = [!account && "SNOWFLAKE_ACCOUNT", !token && "SNOWFLAKE_CORTEX_TOKEN"].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.`,
|
||||
`Snowflake Cortex: missing credentials (${missing}). Provide a bearer token (OAuth, JWT, or PAT) via env var, opencode auth, or provider options.`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -871,66 +877,72 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||
|
||||
const baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
|
||||
|
||||
const options: Record<string, any> = { baseURL, apiKey: token }
|
||||
|
||||
// Only skip provider-level fetch when the token is from OAuth with no override.
|
||||
// For OAuth tokens, the plugin auth loader's combined fetch handles
|
||||
// OAuth refresh + snowflake transformations in one place.
|
||||
// For env/config/API-key tokens, the provider fetch applies snowflake
|
||||
// transformations directly.
|
||||
const useOAuthHandler = oauthToken !== undefined && envToken === undefined && apiKeyToken === undefined && configToken === undefined
|
||||
if (!useOAuthHandler) {
|
||||
options.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)
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
options,
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
276
packages/opencode/test/plugin/snowflake-cortex.test.ts
Normal file
276
packages/opencode/test/plugin/snowflake-cortex.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
import { oauthScope, SnowflakeCortexAuthPlugin } from "../../src/plugin/snowflake-cortex"
|
||||
|
||||
function makeInput() {
|
||||
let auth: any = {
|
||||
type: "oauth",
|
||||
access: "access-old",
|
||||
refresh: "refresh-old",
|
||||
expires: Date.now() + 3600_000,
|
||||
accountId: "myorg-myaccount",
|
||||
}
|
||||
const setCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
return {
|
||||
getAuth: async () => auth,
|
||||
setAuth: (next: any) => {
|
||||
auth = next
|
||||
},
|
||||
input: {
|
||||
client: {
|
||||
auth: {
|
||||
set: async (request: any) => {
|
||||
setCalls.push(request)
|
||||
auth = request.body
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
setCalls,
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.snowflake-cortex", () => {
|
||||
test("oauthScope uses Snowflake-compatible scope values", () => {
|
||||
expect(oauthScope(undefined)).toBe("refresh_token")
|
||||
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
|
||||
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
|
||||
})
|
||||
|
||||
test("loader returns empty options when auth is not oauth", async () => {
|
||||
const hooks = await SnowflakeCortexAuthPlugin({} as any)
|
||||
const options = await hooks.auth!.loader!(async () => ({ type: "api", key: "token" }) as any, {} as any)
|
||||
expect(options).toEqual({})
|
||||
})
|
||||
|
||||
test("loader injects bearer header and preserves custom headers", async () => {
|
||||
const { input, getAuth, setAuth } = makeInput()
|
||||
setAuth({
|
||||
type: "oauth",
|
||||
access: "access-live",
|
||||
refresh: "refresh-live",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
expect(options.apiKey).toBe(OAUTH_DUMMY_KEY)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const captured: Headers[] = []
|
||||
globalThis.fetch = (async (_request, init) => {
|
||||
captured.push(new Headers(init?.headers))
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await options.fetch("https://example.test/v1/chat", {
|
||||
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(captured).toHaveLength(1)
|
||||
expect(captured[0].get("authorization")).toBe("Bearer access-live")
|
||||
expect(captured[0].get("x-keep")).toBe("yes")
|
||||
expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
|
||||
})
|
||||
|
||||
test("loader refreshes expired token with single-flight and persists refreshed oauth", async () => {
|
||||
const { input, getAuth, setCalls } = makeInput()
|
||||
let refreshCalls = 0
|
||||
const apiAuthHeaders: string[] = []
|
||||
|
||||
// Must mock fetch before calling loader because startup refresh triggers for expires: 0
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
const url =
|
||||
typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
|
||||
|
||||
if (url.includes("/oauth/token-request")) {
|
||||
refreshCalls += 1
|
||||
const body = new URLSearchParams(String(init?.body ?? ""))
|
||||
expect(body.get("grant_type")).toBe("refresh_token")
|
||||
expect(body.get("refresh_token")).toBe("refresh-old")
|
||||
expect(new Headers(init?.headers).get("authorization")).toMatch(/^Basic /)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return Response.json({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 })
|
||||
}
|
||||
|
||||
apiAuthHeaders.push(new Headers(init?.headers).get("authorization") || "")
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(
|
||||
async () => ({
|
||||
type: "oauth",
|
||||
access: "access-expired",
|
||||
refresh: "refresh-old",
|
||||
expires: 0,
|
||||
accountId: "myorg-myaccount",
|
||||
}) as any,
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
options.fetch("https://example.test/v1/chat", { headers: {} }),
|
||||
options.fetch("https://example.test/v1/chat", { headers: {} }),
|
||||
])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(refreshCalls).toBe(1)
|
||||
expect(apiAuthHeaders).toEqual(["Bearer access-new", "Bearer access-new"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0] as any).body).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-new",
|
||||
refresh: "refresh-new",
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
})
|
||||
|
||||
test("loader retries once after 401 by refreshing token", async () => {
|
||||
const { input, getAuth, setCalls } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(
|
||||
async () => ({
|
||||
type: "oauth",
|
||||
access: "access-stale",
|
||||
refresh: "refresh-old",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "myorg-myaccount",
|
||||
}) as any,
|
||||
{} as any,
|
||||
)
|
||||
|
||||
let apiCalls = 0
|
||||
const seenAuth: string[] = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
const url =
|
||||
typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
|
||||
|
||||
if (url.includes("/oauth/token-request")) {
|
||||
return Response.json({ access_token: "access-fresh", refresh_token: "refresh-fresh", expires_in: 3600 })
|
||||
}
|
||||
|
||||
apiCalls += 1
|
||||
seenAuth.push(new Headers(init?.headers).get("authorization") || "")
|
||||
if (apiCalls === 1) return new Response("unauthorized", { status: 401 })
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", { headers: {} })
|
||||
expect(response.status).toBe(200)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(apiCalls).toBe(2)
|
||||
expect(seenAuth).toEqual(["Bearer access-stale", "Bearer access-fresh"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0] as any).body).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-fresh",
|
||||
refresh: "refresh-fresh",
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
})
|
||||
|
||||
test("loader converts max_tokens to max_completion_tokens in request body", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
let sentBody: string | undefined
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
sentBody = typeof init?.body === "string" ? init.body : undefined
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 4096, messages: [] }),
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(sentBody).toBeDefined()
|
||||
const parsed = JSON.parse(sentBody!)
|
||||
expect(parsed.max_completion_tokens).toBe(4096)
|
||||
expect(parsed.max_tokens).toBeUndefined()
|
||||
expect(parsed.model).toBe("claude-sonnet-4-5")
|
||||
})
|
||||
|
||||
test("loader maps 400 'conversation complete' to 200 stop", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify({ message: "Conversation complete" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "test", messages: [] }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(body.choices[0].finish_reason).toBe("stop")
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("loader fixes empty role in SSE stream", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const sseChunk = `data: {"choices":[{"delta":{"role":"","content":"hello"}}]}\n\n`
|
||||
globalThis.fetch = (async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(ctrl) {
|
||||
ctrl.enqueue(new TextEncoder().encode(sseChunk))
|
||||
ctrl.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "test", messages: [], stream: true }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const reader = response.body!.getReader()
|
||||
const { value } = await reader.read()
|
||||
const text = new TextDecoder().decode(value)
|
||||
expect(text).not.toContain('"role":""')
|
||||
expect(text).toContain('"role":"assistant"')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue