mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 00:16:22 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f84295abd | ||
|
|
7b8a94e243 | ||
|
|
00908589a0 |
@@ -1,90 +1,272 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Clock, Deferred, Effect, Option, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { configuredSettings } from "./configured.js"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
// Exported for testing: intercepts Cortex-specific request/response quirks.
|
||||
export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
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.status === 400) {
|
||||
try {
|
||||
const errorData = (await response.clone().json()) as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
const providerID = Provider.ID.make("snowflake-cortex")
|
||||
const integrationID = Integration.ID.make(providerID)
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const clientID = "LOCAL_APPLICATION"
|
||||
const accountForm = Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "account",
|
||||
title: "Snowflake account",
|
||||
placeholder: "myorg-myaccount",
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.NonEmptyString,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
const decodeError = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({ message: Schema.optional(Schema.Unknown), error: Schema.optional(Schema.Unknown) }),
|
||||
),
|
||||
)
|
||||
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake.cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.make("snowflake-cortex")) return
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
process.env.SNOWFLAKE_CORTEX_PAT ??
|
||||
(typeof evt.options.token === "string" ? evt.options.token : undefined) ??
|
||||
(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,
|
||||
...(token ? { apiKey: token } : {}),
|
||||
fetch: cortexFetch(upstream) as typeof fetch,
|
||||
} as any)
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const bus = yield* Bus.Service
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const account = { value: "" }
|
||||
const saved = Effect.gen(function* () {
|
||||
const connection = yield* integrations.connection.active(integrationID)
|
||||
return connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined
|
||||
})
|
||||
const token = Effect.fn(function* (account: string, form: Record<string, string>, current?: string) {
|
||||
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.post(`${issuer(account)}/oauth/token-request`).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
Accept: "application/json",
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
// Same built-in OAuth client and Basic header as V1; this is not a user password.
|
||||
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
|
||||
}),
|
||||
HttpClientRequest.bodyUrlParams({ ...form, client_id: clientID }),
|
||||
),
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300)
|
||||
return yield* Effect.fail(
|
||||
new Error(`Snowflake token request failed (${response.status}): ${yield* response.text}`),
|
||||
)
|
||||
const tokens = yield* HttpClientResponse.schemaBodyJson(Token)(response)
|
||||
const refresh = tokens.refresh_token || current
|
||||
if (!refresh) return yield* Effect.fail(new Error("Snowflake token response did not include refresh_token"))
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: tokens.access_token,
|
||||
refresh,
|
||||
expires: (yield* Clock.currentTimeMillis) + (tokens.expires_in ?? 600) * 1000,
|
||||
metadata: { account },
|
||||
})
|
||||
})
|
||||
const refresh = (value: Credential.OAuth) =>
|
||||
token(
|
||||
normalizeAccount(value.metadata?.account),
|
||||
{ grant_type: "refresh_token", refresh_token: value.refresh },
|
||||
value.refresh,
|
||||
)
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { type: "key", label: "Paste PAT or bearer token", form: accountForm },
|
||||
})
|
||||
// SNOWFLAKE_ACCOUNT configures the endpoint; it is not a token.
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
|
||||
})
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
form: [...accountForm, { type: "string", key: "role", title: "Snowflake role (optional)" }],
|
||||
},
|
||||
refresh,
|
||||
label: (value) => normalizeAccount(value.metadata?.account),
|
||||
authorize: (answer) =>
|
||||
Effect.gen(function* () {
|
||||
const account = normalizeAccount(answer.account)
|
||||
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
|
||||
const role = typeof answer.role === "string" ? answer.role.trim() : ""
|
||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(48))).toString("base64url")
|
||||
const challenge = Buffer.from(
|
||||
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
|
||||
).toString("base64url")
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== "/") {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
const value = url.searchParams.get("code")
|
||||
const error =
|
||||
url.searchParams.get("state") !== state
|
||||
? "Invalid OAuth state"
|
||||
: url.searchParams.get("error_description") ||
|
||||
url.searchParams.get("error") ||
|
||||
(!value ? "Missing authorization code" : undefined)
|
||||
Effect.runFork(error ? Deferred.fail(code, new Error(error)) : Deferred.succeed(code, value ?? ""))
|
||||
response
|
||||
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
error
|
||||
? OauthCallbackPage.error(error, { provider: "Snowflake" })
|
||||
: OauthCallbackPage.success({ provider: "Snowflake" }),
|
||||
)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
yield* Effect.tryPromise(() => EventEmitter.once(server.listen(0, "127.0.0.1"), "listening"))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string")
|
||||
return yield* Effect.fail(new Error("Missing OAuth callback port"))
|
||||
const redirect = `http://127.0.0.1:${address.port}/`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${issuer(account)}/oauth/authorize?${new URLSearchParams({
|
||||
client_id: clientID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
state,
|
||||
scope: !role
|
||||
? "refresh_token"
|
||||
: /^[-_A-Za-z0-9]+$/.test(role)
|
||||
? `refresh_token session:role:${role}`
|
||||
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
}).toString()}`,
|
||||
instructions: "Complete Snowflake sign-in in your browser.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((code) =>
|
||||
token(account, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirect,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const load = Effect.gen(function* () {
|
||||
const value = (yield* saved)?.value
|
||||
account.value = normalizeAccount(
|
||||
process.env.SNOWFLAKE_ACCOUNT ??
|
||||
(value?.type === "key"
|
||||
? (value.configuration?.account ?? value.metadata?.account)
|
||||
: value?.metadata?.account) ??
|
||||
configured?.account,
|
||||
)
|
||||
})
|
||||
yield* load
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
const item = catalog.provider.get(providerID)
|
||||
if (!item) return
|
||||
const settings = { ...item.provider.settings, ...configured }
|
||||
item.provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
item.provider.settings = {
|
||||
...settings,
|
||||
provider: providerID,
|
||||
baseURL: endpoint(settings.baseURL, account.value),
|
||||
...(typeof settings.token === "string" ? { apiKey: settings.token } : {}),
|
||||
}
|
||||
for (const model of item.models.values()) {
|
||||
model.package = item.provider.package
|
||||
model.compatibility = { maxTokensField: "max_completion_tokens", ...model.compatibility }
|
||||
if (model.settings?.baseURL !== undefined)
|
||||
model.settings.baseURL = endpoint(model.settings.baseURL, account.value)
|
||||
}
|
||||
})
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(() => load.pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
// Model resolution already supplies and refreshes stored credentials on each attempt.
|
||||
const token = envToken()
|
||||
if (token) event.request.headers.set("authorization", `Bearer ${token}`)
|
||||
event.request.headers.set("user-agent", App.useragent(ctx.app))
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"http.response",
|
||||
Effect.fn(function* (event) {
|
||||
if (event.response.status !== 400) return
|
||||
const error = Option.getOrUndefined(decodeError(yield* Effect.promise(() => event.response.clone().text())))
|
||||
// oxlint-disable-next-line typescript-eslint/no-base-to-string -- Preserve V1's error-body coercion.
|
||||
const message = String(error?.message || error?.error || "")
|
||||
if (!message.toLowerCase().includes("conversation complete")) return
|
||||
event.response = new Response(
|
||||
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"retry",
|
||||
Effect.fn(function* (event) {
|
||||
if (event.error.status !== 401 || event.attempt !== 2 || envToken()) return
|
||||
const current = yield* saved
|
||||
if (current?.value.type !== "oauth" || current.value.methodID !== methodID) return
|
||||
yield* credentials.update(current.id, { value: yield* refresh(current.value).pipe(Effect.orDie) })
|
||||
event.decision = { retry: true, delay: 0 }
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function normalizeAccount(value: unknown) {
|
||||
if (typeof value !== "string") return ""
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\.snowflakecomputing\.com$/i, "")
|
||||
}
|
||||
|
||||
function issuer(account: string) {
|
||||
return `https://${account}.snowflakecomputing.com`
|
||||
}
|
||||
|
||||
function endpoint(value: unknown, account: string) {
|
||||
const baseURL = typeof value === "string" ? value : `${issuer("${SNOWFLAKE_ACCOUNT}")}/api/v2/cortex/v1`
|
||||
return account ? baseURL.replaceAll("${SNOWFLAKE_ACCOUNT}", account) : baseURL
|
||||
}
|
||||
|
||||
function envToken() {
|
||||
return process.env.SNOWFLAKE_CORTEX_TOKEN ?? process.env.SNOWFLAKE_CORTEX_PAT
|
||||
}
|
||||
|
||||
@@ -1,262 +1,216 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SnowflakeCortexPlugin } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Schedule, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, SessionModelTransport.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))),
|
||||
)
|
||||
const providerID = Provider.ID.make("snowflake-cortex")
|
||||
const integrationID = Integration.ID.make(providerID)
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const modelID = Model.ID.make("claude-sonnet-4-6")
|
||||
const env = { SNOWFLAKE_ACCOUNT: undefined, SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }
|
||||
const endpoint = "https://myorg-myaccount.snowflakecomputing.com"
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const fixture = Effect.fn(function* () {
|
||||
const requests: Request[] = []
|
||||
const replies: Response[] = []
|
||||
const http = HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie))
|
||||
const response = replies.shift()
|
||||
if (!response) throw new Error(`Unexpected request: ${request.url}`)
|
||||
return HttpClientResponse.fromWeb(request, response)
|
||||
}),
|
||||
)
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const sessions = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* SnowflakeCortexPlugin.effect(host)
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex")
|
||||
const ids = ProviderPlugins.map((p) => p.id)
|
||||
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai.compatible"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-snowflake-cortex providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("openai"), Model.ID.make("gpt-4")),
|
||||
modelID: Model.ID.make("gpt-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
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 aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
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 aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
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("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
|
||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
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.token when no Snowflake env token is set", () =>
|
||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
name: "snowflake-cortex",
|
||||
baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
|
||||
token: "options-token",
|
||||
},
|
||||
})
|
||||
expect(result.sdk).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("sets includeUsage on the SDK options", () =>
|
||||
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
})
|
||||
expect(result.options.includeUsage).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
describe("cortexFetch", () => {
|
||||
test("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 }),
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1" }
|
||||
})
|
||||
const body = JSON.parse(captured[0].body as string)
|
||||
expect(body.max_completion_tokens).toBe(1024)
|
||||
expect(body.max_tokens).toBeUndefined()
|
||||
})
|
||||
|
||||
test("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)
|
||||
})
|
||||
|
||||
test("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")
|
||||
})
|
||||
|
||||
test("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)
|
||||
})
|
||||
|
||||
test("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)
|
||||
})
|
||||
|
||||
test("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)
|
||||
})
|
||||
|
||||
test("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":""')
|
||||
editor.model.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* SnowflakeCortexPlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||
const session = yield* sessions.create({ location: Location.Ref.make({ directory: location.directory }) })
|
||||
const scope = {
|
||||
sessionID: session.id,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: modelID }),
|
||||
}
|
||||
const send = Effect.gen(function* () {
|
||||
const connection = yield* integrations.connection.active(integrationID)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const model = yield* Effect.gen(function* () {
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
if (!model || String(model.settings?.baseURL).includes("${")) return yield* Effect.fail("Catalog pending")
|
||||
return model
|
||||
}).pipe(Effect.retry({ times: 100, schedule: Schedule.spaced("1 millis") }))
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(model, credential)
|
||||
const service = yield* SessionModelRequest.Service
|
||||
const prepared = yield* service.prepare({
|
||||
scope: { session, agentID: scope.agent, model: SessionRunnerModel.resolved(resolved, model) },
|
||||
transcript: { system: [], messages: [Message.user("Hello")] },
|
||||
})
|
||||
return yield* LLMClient.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)),
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
)
|
||||
}).pipe(Effect.provide(SessionModelRequest.layer))
|
||||
return { requests, replies, integrations, hooks, scope, send }
|
||||
})
|
||||
|
||||
const status = Effect.fn(function* (attemptID: Integration.AttemptID) {
|
||||
const integrations = yield* Integration.Service
|
||||
return yield* integrations.oauth.status({ integrationID, attemptID }).pipe(
|
||||
Effect.filterOrFail(
|
||||
(status) => status.status !== "pending",
|
||||
() => "OAuth pending",
|
||||
),
|
||||
Effect.retry({ times: 100, schedule: Schedule.spaced("1 millis") }),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("browser OAuth supplies the native endpoint/token and refreshes a rejected token once", () =>
|
||||
withEnv(env, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
const attempt = yield* test.integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID,
|
||||
answer: { account: `${endpoint}///`, role: "My Role" },
|
||||
})
|
||||
const url = new URL(attempt.url)
|
||||
expect(url.origin + url.pathname).toBe(`${endpoint}/oauth/authorize`)
|
||||
expect(url.searchParams.get("scope")).toBe("refresh_token session:role-encoded:My%20Role")
|
||||
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
|
||||
callback.searchParams.set("state", url.searchParams.get("state") ?? "")
|
||||
callback.searchParams.set("code", "auth-code")
|
||||
test.replies.push(Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }))
|
||||
yield* Effect.promise(() => fetch(callback))
|
||||
expect((yield* status(attempt.attemptID)).status).toBe("complete")
|
||||
const exchange = test.requests[0]
|
||||
expect(exchange.url).toBe(`${endpoint}/oauth/token-request`)
|
||||
expect(exchange.headers.get("authorization")).toBe(
|
||||
`Basic ${Buffer.from("LOCAL_APPLICATION:LOCAL_APPLICATION").toString("base64")}`,
|
||||
)
|
||||
const form = new URLSearchParams(yield* Effect.promise(() => exchange.text()))
|
||||
expect(Object.fromEntries(form)).toMatchObject({
|
||||
grant_type: "authorization_code",
|
||||
code: "auth-code",
|
||||
redirect_uri: url.searchParams.get("redirect_uri"),
|
||||
})
|
||||
const challenge = Buffer.from(
|
||||
yield* Effect.promise(() =>
|
||||
crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")),
|
||||
),
|
||||
).toString("base64url")
|
||||
expect(url.searchParams.get("code_challenge")).toBe(challenge)
|
||||
|
||||
test.replies.push(new Response("Unauthorized", { status: 401 }))
|
||||
expect((yield* test.send.pipe(Effect.exit))._tag).toBe("Failure")
|
||||
expect(test.requests[1].headers.get("authorization")).toBe("Bearer access")
|
||||
test.replies.push(Response.json({ access_token: "renewed", refresh_token: "", expires_in: 3600 }))
|
||||
const retry = {
|
||||
...test.scope,
|
||||
error: { type: "provider.authentication", message: "Unauthorized", status: 401 },
|
||||
attempt: 2,
|
||||
decision: { retry: false as const },
|
||||
}
|
||||
expect((yield* test.hooks.trigger("session", "retry", retry)).decision).toEqual({ retry: true, delay: 0 })
|
||||
const refresh = new URLSearchParams(yield* Effect.promise(() => test.requests[2].text()))
|
||||
expect(Object.fromEntries(refresh)).toMatchObject({ grant_type: "refresh_token", refresh_token: "refresh" })
|
||||
test.replies.push(Response.json({ message: "Conversation complete", error: {} }, { status: 400 }))
|
||||
expect((yield* test.send).find((event) => event.type === "finish")?.reason.normalized).toBe("stop")
|
||||
expect(test.requests[3].headers.get("authorization")).toBe("Bearer renewed")
|
||||
expect(
|
||||
(yield* test.hooks.trigger("session", "retry", { ...retry, attempt: 3, decision: { retry: false } })).decision
|
||||
.retry,
|
||||
).toBe(false)
|
||||
const credentials = yield* Credential.Service
|
||||
expect((yield* credentials.list(integrationID))[0]?.value).toMatchObject({
|
||||
access: "renewed",
|
||||
refresh: "refresh",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("manual PAT uses its account and native compatibility rather than an SDK request rewrite", () =>
|
||||
withEnv(env, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
yield* test.integrations.connection.key({ integrationID, key: "pat", answer: { account: `${endpoint}/` } })
|
||||
yield* test.hooks.register(
|
||||
"session",
|
||||
"context",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.maxTokens = 1024
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
test.replies.push(
|
||||
new Response(
|
||||
'data: {"choices":[{"index":0,"delta":{"role":"","content":"Hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
expect(yield* test.send).toContainEqual(expect.objectContaining({ type: "text-delta", text: "Hello" }))
|
||||
const request = test.requests[0]
|
||||
expect(request.url).toBe(`${endpoint}/api/v2/cortex/v1/chat/completions`)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer pat")
|
||||
const body = yield* Effect.promise(() => request.json())
|
||||
expect(body).toMatchObject({ model: modelID, max_completion_tokens: 1024 })
|
||||
expect(body).not.toHaveProperty("max_tokens")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a mismatched callback state before token exchange", () =>
|
||||
withEnv(env, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
const attempt = yield* test.integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID,
|
||||
answer: { account: "myorg-myaccount" },
|
||||
})
|
||||
const callback = new URL(new URL(attempt.url).searchParams.get("redirect_uri") ?? "")
|
||||
callback.searchParams.set("state", "wrong")
|
||||
callback.searchParams.set("code", "forged")
|
||||
expect((yield* Effect.promise(() => fetch(callback))).status).toBe(400)
|
||||
expect(yield* status(attempt.attemptID)).toMatchObject({ status: "failed", message: "Invalid OAuth state" })
|
||||
expect(test.requests).toHaveLength(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user