refactor(llm): move auth secret from headers onto ModelRef.apiKey

Add an optional `apiKey` field to `ModelRef` so authentication is no
longer baked into `model.headers` at construction time. Each provider
adapter now passes an `Auth` to `Adapter.fromProtocol` that reads
`request.model.apiKey` per request:

- OpenAI Chat / Responses / OpenAI-compatible Chat: `Auth.bearer`
- Anthropic Messages:  `Auth.apiKeyHeader("x-api-key")`
- Gemini:              `Auth.apiKeyHeader("x-goog-api-key")`
- Bedrock Converse:    custom auth that uses `apiKey` for Bearer auth
                       and falls back to SigV4 with AWS credentials

The `model()` constructors no longer fold the API key into
`model.headers`. The OpenCode bridge sets `apiKey` directly instead of
building auth headers via the now-deleted `authHeader` helper. Test
assertions move from `headers: { authorization: "Bearer ..." }` to
`apiKey: "..."`.
This commit is contained in:
Kit Langton
2026-04-28 18:27:30 -04:00
parent 4f294852a6
commit 5d08e28cd9
12 changed files with 60 additions and 73 deletions
@@ -1,5 +1,6 @@
import { Effect, Schema } from "effect"
import { Adapter } from "../adapter"
import { Auth } from "../auth"
import { Endpoint } from "../endpoint"
import { Framing } from "../framing"
import { capabilities, model as llmModel, type ModelInput } from "../llm"
@@ -520,17 +521,16 @@ export const adapter = Adapter.fromProtocol({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.anthropic.com/v1", path: "/messages" }),
auth: Auth.apiKeyHeader("x-api-key"),
framing: Framing.sse,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
export const model = (input: AnthropicMessagesModelInput) => {
const { apiKey, headers, ...rest } = input
return llmModel({
...rest,
export const model = (input: AnthropicMessagesModelInput) =>
llmModel({
...input,
provider: "anthropic",
protocol: "anthropic-messages",
headers: apiKey ? { ...headers, "x-api-key": apiKey } : headers,
capabilities: input.capabilities ?? capabilities({
output: { reasoning: true },
tools: { calls: true, streamingInput: true },
@@ -538,6 +538,5 @@ export const model = (input: AnthropicMessagesModelInput) => {
reasoning: { efforts: ["low", "medium", "high", "xhigh", "max"], summaries: false, encryptedContent: true },
}),
})
}
export * as AnthropicMessages from "./anthropic-messages"
+6 -12
View File
@@ -524,11 +524,6 @@ const credentialsFromInput = (request: LLMRequest): BedrockCredentials | undefin
Option.getOrUndefined,
)
const isBearerAuth = (headers: Record<string, string> | undefined) => {
const auth = headers?.authorization ?? headers?.Authorization
return typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")
}
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -555,8 +550,8 @@ const signRequest = (input: {
})
/**
* Bedrock auth. Bearer API key wins if `model.headers.authorization` is set;
* otherwise we sign the request with SigV4 using AWS credentials from
* Bedrock auth. `model.apiKey` (Bedrock's newer Bearer API key auth) wins if
* set; otherwise we sign the request with SigV4 using AWS credentials from
* `model.native.aws_credentials`. SigV4 must sign the exact bytes that get
* sent, so the `content-type: application/json` header is included in the
* signing input — `jsonPost` then sets the same value below and the signature
@@ -564,11 +559,12 @@ const signRequest = (input: {
*/
const auth: Auth = (input) =>
Effect.gen(function* () {
if (isBearerAuth(input.headers)) return input.headers
const apiKey = input.request.model.apiKey
if (apiKey) return { ...input.headers, authorization: `Bearer ${apiKey}` }
const credentials = credentialsFromInput(input.request)
if (!credentials) {
return yield* invalid(
"Bedrock Converse requires either a Bearer API key in headers or AWS credentials in model.native.aws_credentials",
"Bedrock Converse requires either model.apiKey or AWS credentials in model.native.aws_credentials",
)
}
const headersForSigning: Record<string, string> = {
@@ -841,13 +837,11 @@ export const adapter = Adapter.fromProtocol({
})
export const model = (input: BedrockConverseModelInput) => {
const { apiKey, credentials, headers, ...rest } = input
const authHeaders = apiKey ? { ...headers, authorization: `Bearer ${apiKey}` } : headers
const { credentials, ...rest } = input
return llmModel({
...rest,
provider: "bedrock",
protocol: "bedrock-converse",
headers: authHeaders,
capabilities:
input.capabilities ??
capabilities({
+5 -6
View File
@@ -1,5 +1,6 @@
import { Effect, Schema } from "effect"
import { Adapter } from "../adapter"
import { Auth } from "../auth"
import { Endpoint } from "../endpoint"
import { Framing } from "../framing"
import { capabilities, model as llmModel, type ModelInput } from "../llm"
@@ -476,16 +477,15 @@ export const adapter = Adapter.fromProtocol({
// Gemini's path embeds the model id and pins SSE framing at the URL level.
path: ({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`,
}),
auth: Auth.apiKeyHeader("x-goog-api-key"),
framing: Framing.sse,
})
export const model = (input: GeminiModelInput) => {
const { apiKey, headers, ...rest } = input
return llmModel({
...rest,
export const model = (input: GeminiModelInput) =>
llmModel({
...input,
provider: "google",
protocol: "gemini",
headers: apiKey ? { ...headers, "x-goog-api-key": apiKey } : headers,
capabilities: input.capabilities ?? capabilities({
input: { image: true, audio: true, video: true, pdf: true },
output: { reasoning: true },
@@ -493,6 +493,5 @@ export const model = (input: GeminiModelInput) => {
reasoning: { efforts: ["minimal", "low", "medium", "high", "xhigh", "max"] },
}),
})
}
export * as Gemini from "./gemini"
+5 -6
View File
@@ -1,5 +1,6 @@
import { Effect, Schema } from "effect"
import { Adapter } from "../adapter"
import { Auth } from "../auth"
import { Endpoint } from "../endpoint"
import { Framing } from "../framing"
import { capabilities, model as llmModel, type ModelInput } from "../llm"
@@ -355,19 +356,17 @@ export const adapter = Adapter.fromProtocol({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/chat/completions" }),
auth: Auth.bearer,
framing: Framing.sse,
})
export const model = (input: OpenAIChatModelInput) => {
const { apiKey, headers, ...rest } = input
return llmModel({
...rest,
export const model = (input: OpenAIChatModelInput) =>
llmModel({
...input,
provider: "openai",
protocol: "openai-chat",
headers: apiKey ? { ...headers, authorization: `Bearer ${apiKey}` } : headers,
capabilities: input.capabilities ?? capabilities({ tools: { calls: true, streamingInput: true } }),
})
}
export const includeUsage = adapter.patch("include-usage", {
reason: "request final usage chunk from OpenAI Chat streaming responses",
@@ -1,4 +1,5 @@
import { Adapter } from "../adapter"
import { Auth } from "../auth"
import { Endpoint } from "../endpoint"
import { Framing } from "../framing"
import { capabilities, model as llmModel, type ModelInput } from "../llm"
@@ -35,19 +36,15 @@ export const adapter = Adapter.fromProtocol({
path: "/chat/completions",
required: "OpenAI-compatible Chat requires a baseURL",
}),
auth: Auth.bearer,
framing: Framing.sse,
})
export const model = (input: OpenAICompatibleChatModelInput) => {
const { apiKey, headers, queryParams, native, ...rest } = input
const { queryParams, native, ...rest } = input
return llmModel({
...rest,
protocol: "openai-compatible-chat",
// Match the precedence used by every other adapter: when an `apiKey` is
// supplied, its `Authorization: Bearer ...` wins over caller-provided
// headers. Callers who want to override auth should omit `apiKey` and set
// the header themselves.
headers: apiKey ? { ...headers, authorization: `Bearer ${apiKey}` } : headers,
native: queryParams ? { ...native, queryParams } : native,
capabilities: input.capabilities ?? capabilities({ tools: { calls: true, streamingInput: true } }),
})
@@ -1,5 +1,6 @@
import { Effect, Schema } from "effect"
import { Adapter } from "../adapter"
import { Auth } from "../auth"
import { Endpoint } from "../endpoint"
import { Framing } from "../framing"
import { capabilities, model as llmModel, type ModelInput } from "../llm"
@@ -385,18 +386,16 @@ export const adapter = Adapter.fromProtocol({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/responses" }),
auth: Auth.bearer,
framing: Framing.sse,
})
export const model = (input: OpenAIResponsesModelInput) => {
const { apiKey, headers, ...rest } = input
return llmModel({
...rest,
export const model = (input: OpenAIResponsesModelInput) =>
llmModel({
...input,
provider: "openai",
protocol: "openai-responses",
headers: apiKey ? { ...headers, authorization: `Bearer ${apiKey}` } : headers,
capabilities: input.capabilities ?? capabilities({ tools: { calls: true, streamingInput: true } }),
})
}
export * as OpenAIResponses from "./openai-responses"
+6
View File
@@ -77,6 +77,12 @@ export class ModelRef extends Schema.Class<ModelRef>("LLM.ModelRef")({
provider: ProviderID,
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.
*/
apiKey: Schema.optional(Schema.String),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
capabilities: ModelCapabilities,
limits: ModelLimits,
@@ -259,7 +259,7 @@ describe("Bedrock Converse adapter", () => {
.generate(LLM.updateRequest(baseRequest, { model: unsignedModel }))
.pipe(Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), Effect.flip)
expect(error.message).toContain("Bedrock Converse requires either a Bearer API key")
expect(error.message).toContain("Bedrock Converse requires either model.apiKey")
}),
)
@@ -66,7 +66,7 @@ describe("OpenAI-compatible Chat adapter", () => {
provider: "deepseek",
protocol: "openai-compatible-chat",
baseURL: "https://api.deepseek.test/v1/",
headers: { authorization: "Bearer test-key" },
apiKey: "test-key",
native: { queryParams: { "api-version": "2026-01-01" } },
})
expect(prepared.target).toEqual({
@@ -94,7 +94,7 @@ describe("OpenAI-compatible Chat adapter", () => {
provider: String(model.provider),
protocol: model.protocol,
baseURL: model.baseURL,
headers: model.headers,
apiKey: model.apiKey,
native: model.native,
}
}),
@@ -104,7 +104,7 @@ describe("OpenAI-compatible Chat adapter", () => {
provider,
protocol: "openai-compatible-chat",
baseURL,
headers: { authorization: "Bearer test-key" },
apiKey: "test-key",
native: { openaiCompatibleProvider: provider },
})),
)
+7 -13
View File
@@ -67,20 +67,13 @@ const baseURL = (input: Input, resolution: ProviderResolution, options: Record<s
return resolution.baseURL
}
const authHeader = (auth: ProviderAuth, apiKey: string | undefined): Record<string, string> => {
if (!apiKey) return {}
if (auth === "none") return {}
if (auth === "anthropic-api-key") return { "x-api-key": apiKey }
if (auth === "google-api-key") return { "x-goog-api-key": apiKey }
return { authorization: `Bearer ${apiKey}` }
const apiKey = (input: Input, resolution: ProviderResolution, options: Record<string, unknown>) => {
if (resolution.auth === "none") return undefined
return stringOption(options, "apiKey") ?? input.provider.key
}
const headers = (input: Input, resolution: ProviderResolution, options: Record<string, unknown>) => {
const result = {
...authHeader(resolution.auth, stringOption(options, "apiKey") ?? input.provider.key),
...recordOption(options, "headers"),
...input.model.headers,
}
const headers = (input: Input, options: Record<string, unknown>) => {
const result = { ...recordOption(options, "headers"), ...input.model.headers }
return Object.keys(result).length === 0 ? undefined : result
}
@@ -139,7 +132,8 @@ export const toModelRef = (input: Input): ModelRef | undefined => {
provider: resolution.provider,
protocol: resolution.protocol,
baseURL: baseURL(input, resolution, options),
headers: headers(input, resolution, options),
apiKey: apiKey(input, resolution, options),
headers: headers(input, options),
capabilities: capabilities(input, resolution),
limits: LLM.limits({ context: input.model.limit.context, output: input.model.limit.output }),
native: {
@@ -47,7 +47,7 @@ describe("ProviderLLMBridge", () => {
id: "gpt-5",
provider: "openai",
protocol: "openai-responses",
headers: { authorization: "Bearer openai-key" },
apiKey: "openai-key",
limits: { context: 128_000, output: 32_000 },
})
expect(ref?.capabilities.reasoning.efforts).toEqual(["high"])
@@ -65,8 +65,8 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
protocol: "anthropic-messages",
apiKey: "anthropic-key",
headers: {
"x-api-key": "anthropic-key",
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
},
})
@@ -81,7 +81,7 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
protocol: "gemini",
headers: { "x-goog-api-key": "google-key" },
apiKey: "google-key",
})
expect(ref?.capabilities.tools.streamingInput).toBe(false)
})
@@ -102,7 +102,7 @@ describe("ProviderLLMBridge", () => {
provider: "togetherai",
protocol: "openai-compatible-chat",
baseURL: "https://api.together.xyz/v1",
headers: { authorization: "Bearer together-key" },
apiKey: "together-key",
})
})
@@ -115,7 +115,7 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
provider: "github-copilot",
protocol: "openai-responses",
headers: { authorization: "Bearer copilot-key" },
apiKey: "copilot-key",
})
})
@@ -133,7 +133,7 @@ describe("ProviderLLMBridge", () => {
provider: "azure",
protocol: "openai-responses",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
headers: { authorization: "Bearer azure-key" },
apiKey: "azure-key",
native: { queryParams: { "api-version": "2025-04-01-preview" } },
})
})
@@ -173,8 +173,8 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
protocol: "openai-compatible-chat",
baseURL: "https://custom.cerebras.test/v1",
apiKey: "cerebras-key",
headers: {
authorization: "Bearer cerebras-key",
"X-Cerebras-3rd-Party-Integration": "opencode",
"x-model-header": "1",
},
@@ -193,7 +193,7 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
protocol: "bedrock-converse",
headers: { authorization: "Bearer bedrock-bearer-key" },
apiKey: "bedrock-bearer-key",
})
// Bedrock Converse supports both prompt-level and positional content-block
// cache markers (cachePoint blocks landed in 9d7d518ac).
@@ -146,7 +146,7 @@ describe("LLMNative.request", () => {
id: "gpt-5",
provider: "openai",
protocol: "openai-responses",
headers: { authorization: "Bearer openai-key" },
apiKey: "openai-key",
},
system: [{ type: "text", text: "You are concise." }],
generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 },
@@ -659,7 +659,7 @@ describe("LLMNative.request", () => {
expect(request.model).toMatchObject({
provider: "anthropic",
protocol: "anthropic-messages",
headers: { "x-api-key": "anthropic-key" },
apiKey: "anthropic-key",
})
expect(prepared.target).toMatchObject({
model: "claude-sonnet-4-5",
@@ -729,7 +729,7 @@ describe("LLMNative.request", () => {
provider: "togetherai",
protocol: "openai-compatible-chat",
baseURL: "https://api.together.xyz/v1",
headers: { authorization: "Bearer together-key" },
apiKey: "together-key",
})
expect(prepared.target).toMatchObject({
model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
@@ -791,7 +791,7 @@ describe("LLMNative.request", () => {
provider: "azure",
protocol: "openai-responses",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
headers: { authorization: "Bearer azure-key" },
apiKey: "azure-key",
native: { queryParams: { "api-version": "2025-04-01-preview" } },
})
}))
@@ -815,7 +815,7 @@ describe("LLMNative.request", () => {
provider: "azure",
protocol: "openai-chat",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
headers: { authorization: "Bearer azure-key" },
apiKey: "azure-key",
native: { queryParams: { "api-version": "v1" } },
})
}))
@@ -859,7 +859,7 @@ describe("LLMNative.request", () => {
provider: "google",
protocol: "gemini",
baseURL: "https://generativelanguage.googleapis.com/v1beta",
headers: { "x-goog-api-key": "google-key" },
apiKey: "google-key",
})
expect(prepared.target).toMatchObject({
systemInstruction: { parts: [{ text: "You are concise." }] },