diff --git a/packages/llm/src/provider/anthropic-messages.ts b/packages/llm/src/provider/anthropic-messages.ts index ec9d70bc5b..dabe13ea54 100644 --- a/packages/llm/src/provider/anthropic-messages.ts +++ b/packages/llm/src/provider/anthropic-messages.ts @@ -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" diff --git a/packages/llm/src/provider/bedrock-converse.ts b/packages/llm/src/provider/bedrock-converse.ts index 31759ee8f0..34cc952972 100644 --- a/packages/llm/src/provider/bedrock-converse.ts +++ b/packages/llm/src/provider/bedrock-converse.ts @@ -524,11 +524,6 @@ const credentialsFromInput = (request: LLMRequest): BedrockCredentials | undefin Option.getOrUndefined, ) -const isBearerAuth = (headers: Record | 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 = { @@ -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({ diff --git a/packages/llm/src/provider/gemini.ts b/packages/llm/src/provider/gemini.ts index 46ddfc86d4..9034e674eb 100644 --- a/packages/llm/src/provider/gemini.ts +++ b/packages/llm/src/provider/gemini.ts @@ -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" diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts index e1bb4f7cc8..6b5a38cbbe 100644 --- a/packages/llm/src/provider/openai-chat.ts +++ b/packages/llm/src/provider/openai-chat.ts @@ -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", diff --git a/packages/llm/src/provider/openai-compatible-chat.ts b/packages/llm/src/provider/openai-compatible-chat.ts index 38229db530..580cded039 100644 --- a/packages/llm/src/provider/openai-compatible-chat.ts +++ b/packages/llm/src/provider/openai-compatible-chat.ts @@ -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 } }), }) diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index 1c7cd81f85..5b48e0ebf8 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -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" diff --git a/packages/llm/src/schema.ts b/packages/llm/src/schema.ts index ffb2e082a4..045dfaeaf3 100644 --- a/packages/llm/src/schema.ts +++ b/packages/llm/src/schema.ts @@ -77,6 +77,12 @@ export class ModelRef extends Schema.Class("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, diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 1458dee129..7c725e3fa1 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -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") }), ) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index efaed0e5e9..8cdcf52fb2 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -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 }, })), ) diff --git a/packages/opencode/src/provider/llm-bridge.ts b/packages/opencode/src/provider/llm-bridge.ts index 0c8556eaf3..4c3119293b 100644 --- a/packages/opencode/src/provider/llm-bridge.ts +++ b/packages/opencode/src/provider/llm-bridge.ts @@ -67,20 +67,13 @@ const baseURL = (input: Input, resolution: ProviderResolution, options: Record => { - 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) => { + if (resolution.auth === "none") return undefined + return stringOption(options, "apiKey") ?? input.provider.key } -const headers = (input: Input, resolution: ProviderResolution, options: Record) => { - const result = { - ...authHeader(resolution.auth, stringOption(options, "apiKey") ?? input.provider.key), - ...recordOption(options, "headers"), - ...input.model.headers, - } +const headers = (input: Input, options: Record) => { + 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: { diff --git a/packages/opencode/test/provider/llm-bridge.test.ts b/packages/opencode/test/provider/llm-bridge.test.ts index ecaf7fc2d0..144388b6e2 100644 --- a/packages/opencode/test/provider/llm-bridge.test.ts +++ b/packages/opencode/test/provider/llm-bridge.test.ts @@ -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). diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd048abe2c..76e2d77f9d 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -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." }] },