diff --git a/packages/llm/src/adapter/auth-options.ts b/packages/llm/src/adapter/auth-options.ts new file mode 100644 index 0000000000..946b09e81b --- /dev/null +++ b/packages/llm/src/adapter/auth-options.ts @@ -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 = + | AuthOverride + | (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth) + +export type ModelOptions = Omit & ProviderAuthOption + +export type ModelArgs = Mode extends "optional" + ? readonly [options?: ModelOptions] + : readonly [options: ModelOptions] + +export type ModelFactory = ( + id: string, + ...args: ModelArgs +) => Model + +export * as AuthOptions from "./auth-options" diff --git a/packages/llm/src/adapter/auth-policy.ts b/packages/llm/src/adapter/auth-policy.ts deleted file mode 100644 index ccc78c8206..0000000000 --- a/packages/llm/src/adapter/auth-policy.ts +++ /dev/null @@ -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 - -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 - readonly orElse: (that: Credential) => Credential - readonly bearer: () => Policy - readonly header: (name: string) => Policy - readonly pipe: (f: (self: Credential) => A) => A -} - -export interface Policy { - readonly apply: (input: AuthInput) => Effect.Effect - readonly andThen: (that: Policy) => Policy - readonly orElse: (that: Policy) => Policy - readonly pipe: (f: (self: Policy) => A) => A -} - -const credential = (load: Effect.Effect): 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) => 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" diff --git a/packages/llm/src/adapter/auth.ts b/packages/llm/src/adapter/auth.ts index 023f5fb2ec..72aa2dd4c7 100644 --- a/packages/llm/src/adapter/auth.ts +++ b/packages/llm/src/adapter/auth.ts @@ -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 +type Secret = Redacted.Redacted + +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 + readonly orElse: (that: Credential) => Credential + readonly bearer: () => Auth + readonly header: (name: string) => Auth + readonly pipe: (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 + readonly andThen: (that: Auth) => Auth + readonly orElse: (that: Auth) => Auth + readonly pipe: (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): 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) => 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) => 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 ` 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 => + input.apply(authInput).pipe( + Effect.mapError(toLLMError), + ) export * as Auth from "./auth" diff --git a/packages/llm/src/adapter/client.ts b/packages/llm/src/adapter/client.ts index 5aeda7b0b3..bb6f59bb65 100644 --- a/packages/llm/src/adapter/client.ts +++ b/packages/llm/src/adapter/client.ts @@ -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[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 { readonly protocol: Protocol /** Where the request is sent. */ readonly endpoint: Endpoint - /** - * Per-request transport authentication. Defaults to `Auth.bearer`, which - * sets `Authorization: Bearer ` 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 /** Static / per-request headers added before `auth` runs. */ @@ -227,7 +221,7 @@ export interface MakeInput { export function make( input: MakeInput, ): Adapter { - 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( ...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, diff --git a/packages/llm/src/adapter/index.ts b/packages/llm/src/adapter/index.ts index d112cdef6a..095f694ffe 100644 --- a/packages/llm/src/adapter/index.ts +++ b/packages/llm/src/adapter/index.ts @@ -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" diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index c610e71a5c..a1d8dbf2df 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,4 +1,5 @@ export { LLMClient, modelCapabilities, modelLimits, modelRef } from "./adapter/client" +export { Auth } from "./adapter/auth" export type { AdapterModelInput, AdapterRoutedModelInput, diff --git a/packages/llm/src/protocols/utils/bedrock-auth.ts b/packages/llm/src/protocols/utils/bedrock-auth.ts index 9688b70f8f..c2ab604be2 100644 --- a/packages/llm/src/protocols/utils/bedrock-auth.ts +++ b/packages/llm/src/protocols/utils/bedrock-auth.ts @@ -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 | undefined, credentials: Credentials | undefined) => credentials diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts index 762f34d327..813a349549 100644 --- a/packages/llm/src/providers/azure.ts +++ b/packages/llm/src/providers/azure.ts @@ -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 & { +export type ModelOptions = Omit & 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, diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 1aaf744af1..9c92509b60 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -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 & { +type OpenAIModelInput = Omit & 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> = {}) => { - 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> = {}) => { - return OpenAIChat.model(withOpenAIOptions(id, options)) + return OpenAIChat.model(withOpenAIOptions(id, { ...options, auth: auth(options) })) } export const model = responses diff --git a/packages/llm/src/schema.ts b/packages/llm/src/schema.ts index 1c52a5af85..592a438fda 100644 --- a/packages/llm/src/schema.ts +++ b/packages/llm/src/schema.ts @@ -219,12 +219,10 @@ export class ModelRef extends Schema.Class("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, diff --git a/packages/llm/test/auth-options.types.ts b/packages/llm/test/auth-options.types.ts new file mode 100644 index 0000000000..9587f88b32 --- /dev/null +++ b/packages/llm/test/auth-options.types.ts @@ -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 +} + +type Model = { + readonly id: string +} + +declare const auth: Auth +declare const optionalAuthModel: ModelFactory +declare const requiredAuthModel: ModelFactory + +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 }) diff --git a/packages/llm/test/auth-policy.test.ts b/packages/llm/test/auth.test.ts similarity index 64% rename from packages/llm/test/auth-policy.test.ts rename to packages/llm/test/auth.test.ts index c3698397a1..7be983bd70 100644 --- a/packages/llm/test/auth-policy.test.ts +++ b/packages/llm/test/auth.test.ts @@ -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) => 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")