fix(llm): use Azure api-key auth for OpenAI adapters

This commit is contained in:
Kit Langton
2026-05-01 17:11:44 -04:00
parent e9d84c6db7
commit 652ef9c09a
5 changed files with 84 additions and 1 deletions
+18 -1
View File
@@ -10,7 +10,8 @@ import type { LLMError, LLMRequest } from "./schema"
* 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`).
* Anthropic's `x-api-key`, Gemini's `x-goog-api-key`) or a provider-aware
* helper such as `Auth.openAI` for Azure OpenAI's static `api-key` header.
*
* Adapters that need per-request signing (AWS SigV4, future Vertex IAM,
* future Azure AAD) implement `Auth` as a function that hashes the body,
@@ -52,6 +53,22 @@ const fromApiKey = (from: (apiKey: string) => Record<string, string>): Auth => (
*/
export const bearer: Auth = fromApiKey((key) => ({ authorization: `Bearer ${key}` }))
/**
* OpenAI-compatible auth with Azure OpenAI's static API-key exception. Azure
* Entra/OAuth callers can still pre-set `authorization` and omit `apiKey`.
*/
export const openAI: Auth = ({ request, headers }) => {
const key = request.model.apiKey
if (!key) return Effect.succeed(headers)
if (request.model.provider === "azure") {
return Effect.succeed({
...Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "authorization")),
"api-key": key,
})
}
return Effect.succeed({ ...headers, 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`).
+2
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,6 +356,7 @@ export const adapter = Adapter.fromProtocol({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/chat/completions" }),
auth: Auth.openAI,
framing: Framing.sse,
})
@@ -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,6 +386,7 @@ export const adapter = Adapter.fromProtocol({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/responses" }),
auth: Auth.openAI,
framing: Framing.sse,
})
@@ -83,6 +83,37 @@ describe("OpenAI Chat adapter", () => {
}),
)
it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
Effect.gen(function* () {
yield* LLMClient.make({ adapters: [OpenAIChat.adapter] })
.generate(
LLM.updateRequest(request, {
model: LLM.model({
...model,
provider: "azure",
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}),
}),
)
.pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("api-key")).toBe("azure-key")
expect(web.headers.get("authorization")).toBeNull()
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
it.effect("prepares assistant tool-call and tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.make({ adapters: [OpenAIChat.adapter] }).prepare(
@@ -62,6 +62,37 @@ describe("OpenAI Responses adapter", () => {
}),
)
it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
Effect.gen(function* () {
yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] })
.generate(
LLM.updateRequest(request, {
model: LLM.model({
...model,
provider: "azure",
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}),
}),
)
.pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("api-key")).toBe("azure-key")
expect(web.headers.get("authorization")).toBeNull()
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
it.effect("prepares function call and function output input items", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] }).prepare(