Skip to content
Draft
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
6 changes: 6 additions & 0 deletions packages/ai-config/providers.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,9 @@
"account": {
"type": "string"
},
"connectionName": {
"type": "string"
},
"host": {
"type": "string"
}
Expand Down Expand Up @@ -5431,6 +5434,9 @@
"account": {
"type": "string"
},
"connectionName": {
"type": "string"
},
"host": {
"type": "string"
}
Expand Down
6 changes: 6 additions & 0 deletions packages/ai-config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ export const snowflakeConfigSchema = z
.object({
account: z.string().optional(),
host: z.string().optional(),
/**
* Name of the `connections.toml` connection to use (non-secret). Consumed
* by `@assistant/node`'s Snowflake resolver on Node platforms; ignored in
* Positron, which defers Snowflake credentials to `vscode.authentication`.
*/
connectionName: z.string().optional(),
})
.strict();

Expand Down
2 changes: 1 addition & 1 deletion packages/ai-config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export interface ResolvedConnection {
positaiLogin?: { host?: string; clientId?: string; scope?: string };
aws?: { region?: string; profile?: string };
googleCloud?: { project?: string; location?: string };
snowflake?: { account?: string; host?: string };
snowflake?: { account?: string; host?: string; connectionName?: string };
}

/**
Expand Down
71 changes: 64 additions & 7 deletions packages/ai-provider-bridge/src/model-clients/SnowflakeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,33 @@ import {
import type { ModelClient, ModelClientChatParams } from "./ModelClient";
import { createOpenAICompatibleFetch } from "./openai-compat-fetch";

/** Header carrying the Snowflake auth scheme (real token types are forwarded). */
const TOKEN_TYPE_HEADER = "X-Snowflake-Authorization-Token-Type";
/**
* Internal sentinel token-type: the credential's `apiKey` is a Snowflake session
* token (from external-browser SSO) that must be sent as
* `Authorization: Snowflake Token="..."` rather than a Bearer token. This value
* is consumed by the client and never forwarded to Snowflake.
*/
const SESSION_TOKEN_TYPE = "SESSION";

type FetchFn = (url: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>;

/**
* Wrap a fetch so every request authenticates with a Snowflake **session token**
* (`Authorization: Snowflake Token="..."`). Removes any Bearer/x-api-key header
* the SDK set and the internal token-type sentinel before the request goes out.
*/
function createSnowflakeSessionFetch(sessionToken: string, delegate: FetchFn): FetchFn {
return async (url, init) => {
const headers = new Headers(init?.headers);
headers.delete("x-api-key");
headers.delete(TOKEN_TYPE_HEADER);
headers.set("Authorization", `Snowflake Token="${sessionToken}"`);
return delegate(url, { ...init, headers });
};
}

export class SnowflakeClient implements ModelClient {
private readonly bearerToken: string;
private readonly baseUrl: string;
Expand All @@ -38,6 +65,19 @@ export class SnowflakeClient implements ModelClient {
this.customHeaders = customHeaders;
}

/** True when the token is a session token needing the `Snowflake Token=` scheme. */
private get isSessionAuth(): boolean {
return this.customHeaders?.[TOKEN_TYPE_HEADER] === SESSION_TOKEN_TYPE;
}

/** customHeaders to forward, with the internal session sentinel removed. */
private forwardedHeaders(): Record<string, string> | undefined {
if (!this.customHeaders || !this.isSessionAuth) return this.customHeaders;
const rest = { ...this.customHeaders };
delete rest[TOKEN_TYPE_HEADER];
return Object.keys(rest).length > 0 ? rest : undefined;
}

async chat(params: ModelClientChatParams): Promise<AsyncIterable<LMStreamPart>> {
const effectiveBaseUrl = params.baseUrl ?? this.baseUrl;

Expand Down Expand Up @@ -70,12 +110,21 @@ export class SnowflakeClient implements ModelClient {
params: ModelClientChatParams,
baseUrl: string,
): Promise<AsyncIterable<LMStreamPart>> {
const headers = safeSdkCustomHeaders(this.customHeaders);
const provider = createAnthropic({
authToken: this.bearerToken,
baseURL: baseUrl,
...(headers && { headers }),
});
const headers = safeSdkCustomHeaders(this.forwardedHeaders());
const provider = this.isSessionAuth
? createAnthropic({
// Auth is applied by the session fetch wrapper; this placeholder key
// just satisfies the SDK (its x-api-key header is stripped there).
apiKey: "session-auth",
baseURL: baseUrl,
fetch: createSnowflakeSessionFetch(this.bearerToken, globalThis.fetch),
...(headers && { headers }),
})
: createAnthropic({
authToken: this.bearerToken,
baseURL: baseUrl,
...(headers && { headers }),
});
const model = provider(params.model);

const { abortController, cleanup } = createAbortControllerFromToken(params.cancellationToken);
Expand Down Expand Up @@ -127,10 +176,18 @@ export class SnowflakeClient implements ModelClient {
params: ModelClientChatParams,
baseUrl: string,
): Promise<AsyncIterable<LMStreamPart>> {
// The compat fetch applies OpenAI-spec fix-ups. For session auth we keep a
// non-empty apiKey so it does NOT strip the Authorization header, and wrap
// it so the outer fetch installs the `Snowflake Token=` header last.
const compatFetch = this.isSessionAuth
? createOpenAICompatibleFetch("Snowflake", "session-auth", this.forwardedHeaders())
: createOpenAICompatibleFetch("Snowflake", this.bearerToken, this.customHeaders);
const provider = createOpenAI({
apiKey: this.bearerToken || "sk-placeholder",
baseURL: baseUrl,
fetch: createOpenAICompatibleFetch("Snowflake", this.bearerToken, this.customHeaders),
fetch: this.isSessionAuth
? createSnowflakeSessionFetch(this.bearerToken, compatFetch)
: compatFetch,
});
const model = provider.chat(params.model);

Expand Down
Loading