feat(llm): add composable auth

This commit is contained in:
Kit Langton
2026-05-06 15:34:35 -04:00
parent 02b1d68963
commit fe9b5cf1fb
12 changed files with 261 additions and 186 deletions
+35
View File
@@ -0,0 +1,35 @@
import type { Auth } from "./auth"
export type ApiKeyMode = "optional" | "required"
export type AuthOverride = {
readonly auth: Auth
readonly apiKey?: never
}
export type OptionalApiKeyAuth = {
readonly apiKey?: string
readonly auth?: never
}
export type RequiredApiKeyAuth = {
readonly apiKey: string
readonly auth?: never
}
export type ProviderAuthOption<Mode extends ApiKeyMode> =
| AuthOverride
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
export type ModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> & ProviderAuthOption<Mode>
export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: ModelOptions<Base, Mode>]
: readonly [options: ModelOptions<Base, Mode>]
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (
id: string,
...args: ModelArgs<Base, Mode>
) => Model
export * as AuthOptions from "./auth-options"
-90
View File
@@ -1,90 +0,0 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import type { AuthInput } from "./auth"
type Secret = Redacted.Redacted<string>
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
constructor(readonly source: string) {
super(`Missing auth credential: ${source}`)
}
}
export type CredentialError = MissingCredentialError | Config.ConfigError
export interface Credential {
readonly load: Effect.Effect<Secret, CredentialError>
readonly orElse: (that: Credential) => Credential
readonly bearer: () => Policy
readonly header: (name: string) => Policy
readonly pipe: <A>(f: (self: Credential) => A) => A
}
export interface Policy {
readonly apply: (input: AuthInput) => Effect.Effect<Headers.Headers, CredentialError>
readonly andThen: (that: Policy) => Policy
readonly orElse: (that: Policy) => Policy
readonly pipe: <A>(f: (self: Policy) => A) => A
}
const credential = (load: Effect.Effect<Secret, CredentialError>): Credential => {
const self: Credential = {
load,
orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))),
bearer: () => fromCredential(self, (secret) => ({ authorization: `Bearer ${secret}` })),
header: (name) => fromCredential(self, (secret) => ({ [name]: secret })),
pipe: (f) => f(self),
}
return self
}
const policy = (apply: Policy["apply"]): Policy => {
const self: Policy = {
apply,
andThen: (that) =>
policy((input) =>
apply(input).pipe(Effect.flatMap((headers) => that.apply({ ...input, headers }))),
),
orElse: (that) => policy((input) => apply(input).pipe(Effect.catch(() => that.apply(input)))),
pipe: (f) => f(self),
}
return self
}
const fromCredential = (source: Credential, render: (secret: string) => Headers.Input) =>
policy((input) =>
source.load.pipe(
Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret)))),
),
)
export const value = (secret: string, source = "value") =>
optional(secret, source)
export const optional = (secret: string | undefined, source = "optional value") =>
credential(
secret === undefined || secret === ""
? Effect.fail(new MissingCredentialError(source))
: Effect.succeed(Redacted.make(secret)),
)
export const config = (name: string) =>
credential(
Effect.gen(function* () {
return yield* Config.redacted(name)
}),
)
export const effect = (load: Effect.Effect<Secret, CredentialError>) => credential(load)
export const none = policy((input) => Effect.succeed(input.headers))
export const headers = (input: Headers.Input) => policy((auth) => Effect.succeed(Headers.setAll(auth.headers, input)))
export const bearer = (source: Credential) => source.bearer()
export const header = (name: string) => (source: Credential) => source.header(name)
export * as AuthPolicy from "./auth-policy"
+122 -46
View File
@@ -1,24 +1,19 @@
import { Effect } from "effect"
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import type { LLMError, LLMRequest } from "../schema"
import { InvalidRequestError, type LLMError, type LLMRequest } from "../schema"
/**
* Per-request transport authentication.
*
* Receives the unsigned HTTP request shape (URL, method, body, headers) and
* returns the headers to actually send.
*
* Most adapters use the default `Auth.bearer`, which reads
* `request.model.apiKey` and sets `Authorization: Bearer ...`. Providers
* that use a different header pick `Auth.apiKeyHeader(name)` (e.g.
* Anthropic's `x-api-key`, Gemini's `x-goog-api-key`, Azure OpenAI's
* `api-key`).
*
* Adapters that need per-request signing (AWS SigV4, future Vertex IAM,
* future Azure AAD) implement `Auth` as a function that hashes the body,
* mints a signature, and merges signed headers into the result.
*/
export type Auth = (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>
type Secret = Redacted.Redacted<string>
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
constructor(readonly source: string) {
super(`Missing auth credential: ${source}`)
}
}
export type CredentialError = MissingCredentialError | Config.ConfigError
export type AuthError = CredentialError | LLMError
export interface AuthInput {
readonly request: LLMRequest
@@ -28,38 +23,119 @@ export interface AuthInput {
readonly headers: Headers.Headers
}
/**
* Auth that returns the headers untouched. Use when authentication is
* handled outside the LLM core (e.g. caller supplied `headers.authorization`
* directly, or there is genuinely no auth).
*/
export const passthrough: Auth = ({ headers }) => Effect.succeed(headers)
export interface Credential {
readonly load: Effect.Effect<Secret, CredentialError>
readonly orElse: (that: Credential) => Credential
readonly bearer: () => Auth
readonly header: (name: string) => Auth
readonly pipe: <A>(f: (self: Credential) => A) => A
}
/**
* Builds an `Auth` that reads `request.model.apiKey` and merges the headers
* produced by `from(apiKey)` into the outgoing headers. No-op when
* `model.apiKey` is unset, so callers who pre-set their own auth header keep
* working. The shared core for `bearer` and `apiKeyHeader`.
*/
const fromApiKey =
(from: (apiKey: string) => Headers.Input): Auth =>
({ request, headers }) => {
export interface Auth {
readonly apply: (input: AuthInput) => Effect.Effect<Headers.Headers, AuthError>
readonly andThen: (that: Auth) => Auth
readonly orElse: (that: Auth) => Auth
readonly pipe: <A>(f: (self: Auth) => A) => A
}
export const isAuth = (input: unknown): input is Auth =>
typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function"
const credential = (load: Effect.Effect<Secret, CredentialError>): Credential => {
const self: Credential = {
load,
orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))),
bearer: () => fromCredential(self, (secret) => ({ authorization: `Bearer ${secret}` })),
header: (name) => fromCredential(self, (secret) => ({ [name]: secret })),
pipe: (f) => f(self),
}
return self
}
const auth = (apply: Auth["apply"]): Auth => {
const self: Auth = {
apply,
andThen: (that) => auth((input) => apply(input).pipe(Effect.flatMap((headers) => that.apply({ ...input, headers })))),
orElse: (that) => auth((input) => apply(input).pipe(Effect.catch(() => that.apply(input)))),
pipe: (f) => f(self),
}
return self
}
const fromCredential = (source: Credential, render: (secret: string) => Headers.Input) =>
auth((input) =>
source.load.pipe(
Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret)))),
),
)
export const value = (secret: string, source = "value") => optional(secret, source)
export const optional = (secret: string | undefined, source = "optional value") =>
credential(
secret === undefined || secret === ""
? Effect.fail(new MissingCredentialError(source))
: Effect.succeed(Redacted.make(secret)),
)
export const config = (name: string) =>
credential(
Effect.gen(function* () {
return yield* Config.redacted(name)
}),
)
export const effect = (load: Effect.Effect<Secret, CredentialError>) => credential(load)
export const none = auth((input) => Effect.succeed(input.headers))
export const headers = (input: Headers.Input) => auth((inputAuth) => Effect.succeed(Headers.setAll(inputAuth.headers, input)))
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply)
export const passthrough = none
const fromModelApiKey = (from: (apiKey: string) => Headers.Input) =>
auth(({ request, headers }) => {
const key = request.model.apiKey
if (!key) return Effect.succeed(headers)
return Effect.succeed(Headers.setAll(headers, from(key)))
})
const credentialInput = (source: string | Credential) => typeof source === "string" ? value(source) : source
export function bearer(): Auth
export function bearer(source: string | Credential): Auth
export function bearer(source?: string | Credential) {
if (source === undefined) return fromModelApiKey((key) => ({ authorization: `Bearer ${key}` }))
return credentialInput(source).bearer()
}
export const apiKey = bearer
export const apiKeyHeader = (name: string) => fromModelApiKey((key) => ({ [name]: key }))
export function header(name: string): (source: string | Credential) => Auth
export function header(name: string, source: string | Credential): Auth
export function header(name: string, source?: string | Credential) {
if (source === undefined) return (next: string | Credential) => credentialInput(next).header(name)
return credentialInput(source).header(name)
}
const toLLMError = (error: AuthError): LLMError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new InvalidRequestError({
message: error instanceof MissingCredentialError ? error.message : `Failed to resolve auth config: ${error.message}`,
})
}
return error
}
/**
* `Authorization: Bearer <apiKey>` from `request.model.apiKey`. No-op when
* `model.apiKey` is unset. Used by OpenAI, OpenAI Responses, OpenAI-compatible
* Chat, and (with Bedrock-specific fallback) Bedrock Converse.
*/
export const bearer: Auth = fromApiKey((key) => ({ authorization: `Bearer ${key}` }))
/**
* Set a custom header to `request.model.apiKey`. No-op when `model.apiKey`
* is unset. Used by Anthropic (`x-api-key`) and Gemini (`x-goog-api-key`).
*/
export const apiKeyHeader = (name: string): Auth => fromApiKey((key) => ({ [name]: key }))
export const toEffect = (input: Auth) => (authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> =>
input.apply(authInput).pipe(
Effect.mapError(toLLMError),
)
export * as Auth from "./auth"
+7 -13
View File
@@ -1,7 +1,6 @@
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import type { Auth } from "./auth"
import { bearer as authBearer } from "./auth"
import { Auth, type Auth as AuthDef } from "./auth"
import { type Endpoint, render as renderEndpoint } from "./endpoint"
import { RequestExecutor } from "./executor"
import type { Framing } from "./framing"
@@ -75,11 +74,12 @@ export type HttpOptionsInput = HttpOptions.Input
export type ModelRefInput = Omit<
ConstructorParameters<typeof ModelRef>[0],
"id" | "provider" | "adapter" | "capabilities" | "limits" | "generation" | "http"
"id" | "provider" | "adapter" | "capabilities" | "limits" | "generation" | "http" | "auth"
> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly adapter?: string | AdapterID
readonly auth?: AuthDef
readonly capabilities?: ModelCapabilities.Input
readonly limits?: ModelLimits.Input
readonly generation?: GenerationOptions.Input
@@ -195,14 +195,8 @@ export interface MakeInput<Payload, Frame, Chunk, State> {
readonly protocol: Protocol<Payload, Frame, Chunk, State>
/** Where the request is sent. */
readonly endpoint: Endpoint<Payload>
/**
* Per-request transport authentication. Defaults to `Auth.bearer`, which
* sets `Authorization: Bearer <model.apiKey>` when `model.apiKey` is set
* and is a no-op otherwise. Override with `Auth.apiKeyHeader(name)` for
* providers that use a custom header (Anthropic, Gemini), or supply a
* custom `Auth` for per-request signing (Bedrock SigV4).
*/
readonly auth?: Auth
/** Per-request transport auth. Model-level `Auth` overrides this. */
readonly auth?: AuthDef
/** Stream framing — bytes -> frames before `protocol.chunk` decoding. */
readonly framing: Framing<Frame>
/** Static / per-request headers added before `auth` runs. */
@@ -227,7 +221,7 @@ export interface MakeInput<Payload, Frame, Chunk, State> {
export function make<Payload, Frame, Chunk, State>(
input: MakeInput<Payload, Frame, Chunk, State>,
): Adapter<Payload> {
const auth = input.auth ?? authBearer
const auth = input.auth ?? Auth.bearer()
const protocol = input.protocol
const encodePayload = Schema.encodeSync(Schema.fromJsonString(protocol.payload))
const decodeChunkEffect = Schema.decodeUnknownEffect(protocol.chunk)
@@ -265,7 +259,7 @@ export function make<Payload, Frame, Chunk, State>(
...ctx.request.model.headers,
...ctx.request.http?.headers,
})
const headers = yield* auth({
const headers = yield* Auth.toEffect(Auth.isAuth(ctx.request.model.auth) ? ctx.request.model.auth : auth)({
request: ctx.request,
method: "POST",
url,
+3 -3
View File
@@ -14,12 +14,12 @@ export type {
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
export { AuthPolicy } from "./auth-policy"
export { AuthOptions } from "./auth-options"
export { Endpoint } from "./endpoint"
export { Framing } from "./framing"
export { Protocol } from "./protocol"
export type { Auth as AuthFn, AuthInput } from "./auth"
export type { Credential, CredentialError, Policy } from "./auth-policy"
export type { Auth as AuthShape, AuthInput, Credential, CredentialError } from "./auth"
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options"
export type { Endpoint as EndpointFn, EndpointInput } from "./endpoint"
export type { Framing as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
+1
View File
@@ -1,4 +1,5 @@
export { LLMClient, modelCapabilities, modelLimits, modelRef } from "./adapter/client"
export { Auth } from "./adapter/auth"
export type {
AdapterModelInput,
AdapterRoutedModelInput,
@@ -1,8 +1,7 @@
import { AwsV4Signer } from "aws4fetch"
import { Effect, Option, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth } from "../../adapter/auth"
import type { Auth as AuthFn } from "../../adapter/auth"
import { Auth, type AuthInput } from "../../adapter/auth"
import type { LLMRequest } from "../../schema"
import { ProviderShared } from "../shared"
@@ -75,8 +74,8 @@ const signRequest = (input: {
* set; otherwise sign the exact JSON bytes with SigV4 using credentials from
* `model.native.aws_credentials`.
*/
export const auth: AuthFn = (input) => {
if (input.request.model.apiKey) return Auth.bearer(input)
export const auth = Auth.custom((input: AuthInput) => {
if (input.request.model.apiKey) return Auth.toEffect(Auth.bearer())(input)
return Effect.gen(function* () {
const credentials = credentialsFromInput(input.request)
if (!credentials) {
@@ -88,7 +87,7 @@ export const auth: AuthFn = (input) => {
const signed = yield* signRequest({ url: input.url, body: input.body, headers: headersForSigning, credentials })
return Headers.setAll(headersForSigning, signed)
})
}
})
export const nativeCredentials = (native: Record<string, unknown> | undefined, credentials: Credentials | undefined) =>
credentials
+12 -7
View File
@@ -1,6 +1,5 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../adapter/auth"
import type { Auth as AuthFn } from "../adapter/auth"
import type { ProviderAuthOption } from "../adapter/auth-options"
import { Adapter } from "../adapter/client"
import type { ModelInput } from "../llm"
import { ProviderID } from "../schema"
@@ -10,10 +9,9 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure")
const MISSING_BASE_URL = "Azure OpenAI requires resourceName or baseURL"
const apiKeyAuth = Auth.apiKeyHeader("api-key")
const auth: AuthFn = (input) => apiKeyAuth({ ...input, headers: Headers.remove(input.headers, "authorization") })
const adapterAuth = Auth.remove("authorization").andThen(Auth.apiKeyHeader("api-key"))
export type ModelOptions = Omit<ModelInput, "id" | "provider" | "protocol"> & {
export type ModelOptions = Omit<ModelInput, "id" | "provider" | "protocol" | "apiKey" | "auth"> & ProviderAuthOption<"optional"> & {
readonly resourceName?: string
readonly apiVersion?: string
readonly useCompletionUrls?: boolean
@@ -29,14 +27,14 @@ const resourceBaseURL = (resourceName: string | undefined) => {
const responsesAdapter = OpenAIResponses.makeAdapter({
id: "azure-openai-responses",
auth,
auth: adapterAuth,
defaultBaseURL: false,
endpointRequired: MISSING_BASE_URL,
})
const chatAdapter = OpenAIChat.makeAdapter({
id: "azure-openai-chat",
auth,
auth: adapterAuth,
defaultBaseURL: false,
endpointRequired: MISSING_BASE_URL,
})
@@ -47,6 +45,13 @@ const mapInput = (input: AzureModelInput) => {
const { apiVersion, resourceName, useCompletionUrls, ...rest } = input
return {
...withOpenAIOptions(input.id, rest),
auth: "auth" in input && input.auth
? input.auth
: Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey")
.orElse(Auth.config("AZURE_OPENAI_API_KEY"))
.pipe(Auth.header("api-key")),
),
baseURL: rest.baseURL ?? resourceBaseURL(resourceName),
queryParams: {
...rest.queryParams,
+12 -3
View File
@@ -1,3 +1,5 @@
import { Auth } from "../adapter/auth"
import type { ProviderAuthOption } from "../adapter/auth-options"
import * as OpenAIChat from "../protocols/openai-chat"
import type { OpenAIChatModelInput } from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
@@ -8,16 +10,23 @@ export type { OpenAIOptionsInput } from "./openai-options"
export const adapters = [OpenAIResponses.adapter, OpenAIChat.adapter]
type OpenAIModelInput<ModelInput> = ModelInput & {
type OpenAIModelInput<ModelInput> = Omit<ModelInput, "apiKey" | "auth"> & ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("OPENAI_API_KEY"))
.bearer()
}
export const responses = (id: string, options: OpenAIModelInput<Omit<OpenAIResponsesModelInput, "id">> = {}) => {
return OpenAIResponses.model(withOpenAIOptions(id, options, { textVerbosity: true }))
return OpenAIResponses.model(withOpenAIOptions(id, { ...options, auth: auth(options) }, { textVerbosity: true }))
}
export const chat = (id: string, options: OpenAIModelInput<Omit<OpenAIChatModelInput, "id">> = {}) => {
return OpenAIChat.model(withOpenAIOptions(id, options))
return OpenAIChat.model(withOpenAIOptions(id, { ...options, auth: auth(options) }))
}
export const model = responses
+4 -5
View File
@@ -219,12 +219,10 @@ export class ModelRef extends Schema.Class<ModelRef>("LLM.ModelRef")({
adapter: AdapterID,
protocol: ProtocolID,
baseURL: Schema.optional(Schema.String),
/**
* Auth secret read by `Auth.bearer` / `Auth.apiKeyHeader` at request time.
* Lives here so authentication is not baked into `headers` at construction
* time and the `Auth` axis can actually do its job per request.
*/
/** Provider-specific API key convenience. Provider helpers normalize this into `auth`. */
apiKey: Schema.optional(Schema.String),
/** Optional transport auth policy. Opaque because it may contain functions. */
auth: Schema.optional(Schema.Any),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
/**
* Query params appended to the request URL by `Endpoint.baseURL`. Used for
@@ -260,6 +258,7 @@ export namespace ModelRef {
protocol: model.protocol,
baseURL: model.baseURL,
apiKey: model.apiKey,
auth: model.auth,
headers: model.headers,
queryParams: model.queryParams,
capabilities: model.capabilities,
+38
View File
@@ -0,0 +1,38 @@
import type { Auth } from "../src/adapter/auth"
import type { ModelFactory } from "../src/adapter/auth-options"
type BaseOptions = {
readonly baseURL?: string
readonly headers?: Record<string, string>
}
type Model = {
readonly id: string
}
declare const auth: Auth
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
optionalAuthModel("gpt-4.1-mini")
optionalAuthModel("gpt-4.1-mini", {})
optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test" })
optionalAuthModel("gpt-4.1-mini", { auth })
optionalAuthModel("gpt-4.1-mini", { auth, baseURL: "https://gateway.example.com/v1" })
optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", headers: { "x-source": "test" } })
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", auth })
requiredAuthModel("custom-model", { apiKey: "key" })
requiredAuthModel("custom-model", { auth })
requiredAuthModel("custom-model", { auth, headers: { "x-tenant-id": "tenant" } })
// @ts-expect-error providers without config fallback need apiKey or auth.
requiredAuthModel("custom-model")
// @ts-expect-error providers without config fallback need apiKey or auth.
requiredAuthModel("custom-model", {})
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
requiredAuthModel("custom-model", { apiKey: "key", auth })
@@ -2,11 +2,11 @@ import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { LLM } from "../src"
import { AuthPolicy } from "../src/adapter/auth-policy"
import { Auth } from "../src/adapter/auth"
import { it } from "./lib/effect"
const request = LLM.request({
id: "req_auth_policy",
id: "req_auth",
model: LLM.model({ id: "fake-model", provider: "fake", protocol: "fake" }),
prompt: "hello",
})
@@ -21,10 +21,10 @@ const input = {
const withEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
describe("AuthPolicy", () => {
describe("Auth", () => {
it.effect("renders a config credential as bearer auth", () =>
Effect.gen(function* () {
const headers = yield* AuthPolicy.config("OPENAI_API_KEY").bearer().apply(input).pipe(
const headers = yield* Auth.config("OPENAI_API_KEY").bearer().apply(input).pipe(
withEnv({ OPENAI_API_KEY: "sk-test" }),
)
@@ -35,9 +35,9 @@ describe("AuthPolicy", () => {
it.effect("falls back between credential sources before rendering", () =>
Effect.gen(function* () {
const headers = yield* AuthPolicy.config("PRIMARY_KEY")
.orElse(AuthPolicy.value("fallback-key"))
.pipe(AuthPolicy.header("x-api-key"))
const headers = yield* Auth.config("PRIMARY_KEY")
.orElse(Auth.value("fallback-key"))
.pipe(Auth.header("x-api-key"))
.apply(input)
.pipe(withEnv({}))
@@ -46,10 +46,10 @@ describe("AuthPolicy", () => {
}),
)
it.effect("composes header policies in sequence", () =>
it.effect("composes header auth in sequence", () =>
Effect.gen(function* () {
const headers = yield* AuthPolicy.headers({ "x-tenant-id": "tenant-1" })
.andThen(AuthPolicy.value("gateway-token").bearer())
const headers = yield* Auth.headers({ "x-tenant-id": "tenant-1" })
.andThen(Auth.bearer("gateway-token"))
.apply(input)
expect(headers["x-tenant-id"]).toBe("tenant-1")
@@ -58,11 +58,20 @@ describe("AuthPolicy", () => {
}),
)
it.effect("falls back between full auth policies", () =>
it.effect("renders a direct secret as a custom header", () =>
Effect.gen(function* () {
const headers = yield* AuthPolicy.config("OPENAI_API_KEY")
const headers = yield* Auth.header("api-key", "direct-key").apply(input)
expect(headers["api-key"]).toBe("direct-key")
expect(headers["x-existing"]).toBe("yes")
}),
)
it.effect("falls back between full auth values", () =>
Effect.gen(function* () {
const headers = yield* Auth.config("OPENAI_API_KEY")
.bearer()
.orElse(AuthPolicy.headers({ authorization: "Bearer supplied" }))
.orElse(Auth.headers({ authorization: "Bearer supplied" }))
.apply(input)
.pipe(withEnv({}))
@@ -73,7 +82,7 @@ describe("AuthPolicy", () => {
it.effect("can intentionally leave auth untouched", () =>
Effect.gen(function* () {
const headers = yield* AuthPolicy.none.apply(input)
const headers = yield* Auth.none.apply(input)
expect(headers.authorization).toBeUndefined()
expect(headers["x-existing"]).toBe("yes")