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:
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -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." }] },
|
||||
|
||||
Reference in New Issue
Block a user