fix(llm): use Azure api-key auth for OpenAI adapters
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user