feat(llm): add composable auth
This commit is contained in:
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user