diff --git a/README.md b/README.md index ffabd88a..d3a2f404 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Interactive mode adds slash commands such as `/model`, `/settings`, `/resume`, ` dreb supports both subscription and API-key providers, with model metadata updated in releases. Current provider docs cover subscriptions such as Codex, GitHub Copilot, Gemini CLI, Antigravity, and Kimi; API-key providers such as Anthropic, OpenAI, Azure OpenAI, Google Gemini/Vertex, Amazon Bedrock, Mistral, Groq, Cerebras, xAI, OpenRouter, Vercel AI Gateway, ZAI, OpenCode, Hugging Face, Kimi, and MiniMax; plus custom local/proxy providers. -Custom model configuration can override built-in provider base URLs, merge custom models into built-in providers, set compatibility flags for OpenAI-compatible servers, resolve API keys from shell commands or environment variables, and register providers dynamically from extensions. +Custom model configuration can override built-in provider base URLs, merge custom models into built-in providers, set compatibility flags for OpenAI-compatible servers, resolve API keys from shell commands or environment variables, select Bearer-only auth for Anthropic-compatible endpoints, and register providers dynamically from extensions. When switching models, dreb replays exact-model signed, encrypted, or redacted reasoning state unchanged. Portable structured reasoning is limited to models on the same provider using the OpenAI Chat Completions API when the destination accepts the source's recognized plain field (`reasoning_content`, `reasoning`, or `reasoning_text`); other readable reasoning is carried as labelled plaintext, while opaque redacted or encrypted-only state is not sent to incompatible targets. Provider identity and signature compatibility matter for custom models as well. Switching changes only the outbound request, so returning to the original model can replay its original state unless history has been compacted or pruned. diff --git a/package-lock.json b/package-lock.json index c5063216..2000fc07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.45.3", + "version": "2.45.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.45.3", + "version": "2.45.4", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -10955,7 +10955,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -10984,7 +10984,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -11040,7 +11040,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -11169,7 +11169,7 @@ }, "packages/dashboard": { "name": "@dreb/dashboard", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11401,7 +11401,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -11450,7 +11450,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11483,7 +11483,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.45.3", + "version": "2.45.4", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 6825888c..027b0366 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "node": "22.x" }, "packageManager": "npm@11.5.1", - "version": "2.45.3", + "version": "2.45.4", "dependencies": { "@dreb/coding-agent": "*", "@mariozechner/jiti": "^2.6.5", diff --git a/packages/agent/package.json b/packages/agent/package.json index add02ecf..4bc55fd4 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.45.3", + "version": "2.45.4", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index 3810cdf2..8ba32074 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.45.3", + "version": "2.45.4", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 594dea58..805f560f 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -203,6 +203,17 @@ function mergeHeaders(...headerSources: (Record | undefined)[]): return merged; } +function withoutHeader( + headers: Record | undefined, + headerName: string, +): Record | undefined { + if (!headers) return undefined; + const filtered = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.toLowerCase() !== headerName.toLowerCase()), + ); + return Object.keys(filtered).length > 0 ? filtered : undefined; +} + export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions> = ( model: Model<"anthropic-messages">, context: Context, @@ -546,6 +557,10 @@ function createClient( // interleaved thinking built in. The beta header is deprecated there. const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model); const firstParty = isFirstPartyAnthropic(model.baseUrl); + const bearerAuth = model.authMode === "bearer"; + // authHeader materializes a load-time Authorization header for custom stream + // compatibility. The built-in provider must use the request-time credential. + const modelHeaders = bearerAuth ? withoutHeader(model.headers, "authorization") : model.headers; // Copilot: Bearer auth, selective betas (no fine-grained-tool-streaming) if (model.provider === "github-copilot") { @@ -565,7 +580,7 @@ function createClient( ...(firstParty ? { "anthropic-dangerous-direct-browser-access": "true" } : {}), ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), }, - model.headers, + modelHeaders, dynamicHeaders, optionsHeaders, ), @@ -577,13 +592,14 @@ function createClient( // Third-party Anthropic-compatible endpoints: minimal headers, no Anthropic-specific features if (!firstParty) { const client = new Anthropic({ - apiKey, + apiKey: bearerAuth ? null : apiKey, + authToken: bearerAuth ? apiKey : null, baseURL: model.baseUrl, defaultHeaders: mergeHeaders( { accept: "application/json", }, - model.headers, + modelHeaders, optionsHeaders, ), }); @@ -596,8 +612,8 @@ function createClient( betaFeatures.push("interleaved-thinking-2025-05-14"); } - // OAuth: Bearer auth, Claude Code identity headers - if (isOAuthToken(apiKey)) { + // OAuth: Bearer auth, Claude Code identity headers. Explicit authMode wins over token heuristics. + if (model.authMode === undefined && isOAuthToken(apiKey)) { const client = new Anthropic({ apiKey: null, authToken: apiKey, @@ -611,7 +627,7 @@ function createClient( "user-agent": `claude-cli/${claudeCodeVersion}`, "x-app": "cli", }, - model.headers, + modelHeaders, optionsHeaders, ), }); @@ -619,9 +635,10 @@ function createClient( return { client, isOAuthToken: true }; } - // API key auth (first-party Anthropic) + // API key or explicitly configured Bearer auth (first-party Anthropic) const client = new Anthropic({ - apiKey, + apiKey: bearerAuth ? null : apiKey, + authToken: bearerAuth ? apiKey : null, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders: mergeHeaders( @@ -630,7 +647,7 @@ function createClient( "anthropic-dangerous-direct-browser-access": "true", "anthropic-beta": betaFeatures.join(","), }, - model.headers, + modelHeaders, optionsHeaders, ), }); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 85d4fd8e..8f92e410 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -57,6 +57,9 @@ export type CacheRetention = "none" | "short" | "long"; export type Transport = "sse" | "websocket" | "auto"; +/** Explicit credential transport for providers that support multiple auth schemes. */ +export type AuthMode = "api-key" | "bearer"; + export interface StreamOptions { temperature?: number; maxTokens?: number; @@ -343,6 +346,8 @@ export interface Model { contextWindow: number; maxTokens: number; headers?: Record; + /** Explicit auth transport. When unset, the provider's default and token heuristics apply. */ + authMode?: AuthMode; /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ compat?: TApi extends "openai-completions" ? OpenAICompletionsCompat diff --git a/packages/ai/test/anthropic-auth-headers.test.ts b/packages/ai/test/anthropic-auth-headers.test.ts new file mode 100644 index 00000000..9f94ff40 --- /dev/null +++ b/packages/ai/test/anthropic-auth-headers.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { streamAnthropic } from "../src/providers/anthropic.js"; +import type { Context, Model, StreamOptions } from "../src/types.js"; + +const originalFetch = global.fetch; + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], +}; + +function createModel( + baseUrl: string, + overrides: Partial> = {}, +): Model<"anthropic-messages"> { + return { + id: "test-model", + name: "Test Model", + api: "anthropic-messages", + provider: "test-provider", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + ...overrides, + }; +} + +function createSseResponse(): Response { + const events = [ + { + event: "message_start", + data: { + type: "message_start", + message: { + id: "msg_test", + type: "message", + role: "assistant", + model: "test-model", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + }, + { + event: "message_delta", + data: { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + { event: "message_stop", data: { type: "message_stop" } }, + ]; + const body = `${events.map(({ event, data }) => `event: ${event}\ndata: ${JSON.stringify(data)}`).join("\n\n")}\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +async function captureRequestHeaders(model: Model<"anthropic-messages">, options: StreamOptions): Promise { + let captured: Headers | undefined; + global.fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + captured = new Headers(init?.headers); + return createSseResponse(); + }) as typeof fetch; + + const stream = streamAnthropic(model, context, options); + const errors: string[] = []; + for await (const event of stream) { + if (event.type === "error") errors.push(event.error.errorMessage ?? "unknown error"); + } + + expect(errors).toEqual([]); + expect(captured).toBeDefined(); + return captured!; +} + +afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("Anthropic final auth headers", () => { + it("sends only x-api-key for a default third-party endpoint despite ambient Bearer auth", async () => { + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "ambient-token-that-must-not-leak"); + + const headers = await captureRequestHeaders(createModel("https://proxy.example.com"), { + apiKey: "configured-api-key", + }); + + expect(headers.get("x-api-key")).toBe("configured-api-key"); + expect(headers.has("authorization")).toBe(false); + }); + + it("sends only the request-time Bearer credential and ignores a stale model header", async () => { + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "ambient-token-that-must-not-leak"); + const model = createModel("https://proxy.example.com", { + authMode: "bearer", + headers: { + authorization: "Bearer stale-load-time-key", + "x-custom-header": "preserved", + }, + }); + + const headers = await captureRequestHeaders(model, { apiKey: "request-time-key" }); + + expect(headers.get("authorization")).toBe("Bearer request-time-key"); + expect(headers.has("x-api-key")).toBe(false); + expect(headers.get("x-custom-header")).toBe("preserved"); + }); + + it("lets a caller Authorization header override generated and stale Bearer credentials", async () => { + const model = createModel("https://proxy.example.com", { + authMode: "bearer", + headers: { aUtHoRiZaTiOn: "Bearer stale-load-time-key" }, + }); + + const headers = await captureRequestHeaders(model, { + apiKey: "request-time-key", + headers: { Authorization: "Bearer caller-key" }, + }); + + expect(headers.get("authorization")).toBe("Bearer caller-key"); + expect(headers.has("x-api-key")).toBe(false); + }); + + it("does not mix an ambient Bearer token into first-party API-key auth", async () => { + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "ambient-token-that-must-not-leak"); + + const headers = await captureRequestHeaders(createModel("https://api.anthropic.com"), { + apiKey: "configured-api-key", + }); + + expect(headers.get("x-api-key")).toBe("configured-api-key"); + expect(headers.has("authorization")).toBe(false); + }); +}); diff --git a/packages/ai/test/third-party-anthropic.test.ts b/packages/ai/test/third-party-anthropic.test.ts index fb2f36ff..be304af3 100644 --- a/packages/ai/test/third-party-anthropic.test.ts +++ b/packages/ai/test/third-party-anthropic.test.ts @@ -63,9 +63,9 @@ describe("Third-party Anthropic-compatible endpoints", () => { const opts = mockState.constructorOpts!; expect(opts).toBeDefined(); - // Should use the API key directly (not Bearer/OAuth) + // Should use only the API-key channel and explicitly disable SDK Bearer env fallback expect(opts.apiKey).toBe("test-kimi-key"); - expect(opts.authToken).toBeUndefined(); + expect(opts.authToken).toBeNull(); // Should NOT have dangerouslyAllowBrowser expect(opts.dangerouslyAllowBrowser).toBeUndefined(); @@ -77,6 +77,85 @@ describe("Third-party Anthropic-compatible endpoints", () => { expect(headers["anthropic-dangerous-direct-browser-access"]).toBeUndefined(); }); + it("uses only the Bearer channel when authMode is bearer", async () => { + const { getModel } = await import("../src/models.js"); + const { streamAnthropic } = await import("../src/providers/anthropic.js"); + + const baseModel = getModel("kimi-coding", "k2p5"); + const model = { + ...baseModel, + provider: "custom-bearer-provider", + authMode: "bearer" as const, + headers: { + ...baseModel.headers, + authorization: "Bearer stale-load-time-key", + "x-custom-header": "preserved", + }, + }; + + const s = streamAnthropic(model, context, { + apiKey: "request-time-key", + headers: { Authorization: "Bearer explicit-request-override" }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + + const opts = mockState.constructorOpts!; + expect(opts.apiKey).toBeNull(); + expect(opts.authToken).toBe("request-time-key"); + + const headers = opts.defaultHeaders as Record; + expect(headers.authorization).toBeUndefined(); + expect(headers.Authorization).toBe("Bearer explicit-request-override"); + expect(headers["x-custom-header"]).toBe("preserved"); + }); + + it("preserves first-party OAuth token handling when no auth mode is configured", async () => { + const { getModel } = await import("../src/models.js"); + const { streamAnthropic } = await import("../src/providers/anthropic.js"); + + const model = getModel("anthropic", "claude-sonnet-4-5"); + const s = streamAnthropic(model, context, { apiKey: "sk-ant-oat-test-token" }); + for await (const event of s) { + if (event.type === "error") break; + } + + const opts = mockState.constructorOpts!; + expect(opts.apiKey).toBeNull(); + expect(opts.authToken).toBe("sk-ant-oat-test-token"); + const headers = opts.defaultHeaders as Record; + expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20"); + expect(mockState.streamParams?.system).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: "You are Claude Code, Anthropic's official CLI for Claude." }), + ]), + ); + }); + + it("lets explicit Bearer mode override the first-party OAuth token heuristic", async () => { + const { getModel } = await import("../src/models.js"); + const { streamAnthropic } = await import("../src/providers/anthropic.js"); + + const model = { + ...getModel("anthropic", "claude-sonnet-4-5"), + authMode: "bearer" as const, + headers: { Authorization: "Bearer stale-load-time-key" }, + }; + const s = streamAnthropic(model, context, { apiKey: "sk-ant-oat-explicit-bearer" }); + for await (const event of s) { + if (event.type === "error") break; + } + + const opts = mockState.constructorOpts!; + expect(opts.apiKey).toBeNull(); + expect(opts.authToken).toBe("sk-ant-oat-explicit-bearer"); + const headers = opts.defaultHeaders as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers["anthropic-beta"]).not.toContain("oauth-2025-04-20"); + expect(mockState.streamParams?.system).toEqual([expect.objectContaining({ text: context.systemPrompt })]); + }); + it("includes beta headers and dangerouslyAllowBrowser for first-party Anthropic endpoints", async () => { const { getModel } = await import("../src/models.js"); const { streamAnthropic } = await import("../src/providers/anthropic.js"); @@ -92,6 +171,10 @@ describe("Third-party Anthropic-compatible endpoints", () => { const opts = mockState.constructorOpts!; expect(opts).toBeDefined(); + // First-party API-key auth must also disable SDK Bearer env fallback + expect(opts.apiKey).toBe("sk-test-key"); + expect(opts.authToken).toBeNull(); + // First-party should have dangerouslyAllowBrowser expect(opts.dangerouslyAllowBrowser).toBe(true); diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 238ddcd1..a5e610e9 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -127,7 +127,7 @@ For each built-in provider, dreb maintains a list of tool-capable models, update See [docs/providers.md](docs/providers.md) for detailed setup instructions, including Kimi For Coding notes that distinguish OAuth, API-key, first-party CLI, and Moonshot Open Platform vision support. -**Custom providers & models:** Add providers via `~/.dreb/agent/models.json` if they speak a supported API (OpenAI, Anthropic, Google). For custom APIs or OAuth, use extensions. See [docs/models.md](docs/models.md) and [docs/custom-provider.md](docs/custom-provider.md). +**Custom providers & models:** Add providers via `~/.dreb/agent/models.json` if they speak a supported API (OpenAI, Anthropic, Google), including Bearer-only Anthropic-compatible endpoints. For custom APIs or OAuth, use extensions. See [docs/models.md](docs/models.md) and [docs/custom-provider.md](docs/custom-provider.md). **Reasoning across model switches:** Exact-model signed, encrypted, or redacted reasoning state is replayed unchanged. Structured reasoning is portable only between models that share a provider and the `openai-completions` API when the destination accepts the source's recognized plain field (`reasoning_content`, `reasoning`, or `reasoning_text`). Other readable reasoning is retained as labelled plaintext in `` markers with incompatible protocol metadata removed; opaque redacted or encrypted-only state is omitted for incompatible targets. This outbound conversion does not change session history, so switching back can replay the original state unless it was compacted or pruned. Custom models must also share provider identity and compatible API/signature behavior. diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 73fb95c6..b243e44b 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -194,20 +194,34 @@ Use `qwen-chat-template` instead for local Qwen-compatible servers that read `ch > Use `mistral-conversations` for native Mistral models. > If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed. -### Auth Header +### Bearer Auth -If your provider expects `Authorization: Bearer ` but doesn't use a standard API, set `authHeader: true`: +If your provider expects `Authorization: Bearer `, set `authHeader: true`: ```typescript -dreb.registerProvider("custom-api", { - baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", - authHeader: true, // adds Authorization: Bearer header - api: "openai-completions", +dreb.registerProvider("company-anthropic", { + baseUrl: "https://ai.example.com/anthropic", + apiKey: "COMPANY_ANTHROPIC_TOKEN", + authHeader: true, + api: "anthropic-messages", models: [...] }); ``` +For the built-in `anthropic-messages` implementation, this selects Bearer-only auth: dreb sends the request-time resolved credential as `Authorization` and does not also send `x-api-key`. Without `authHeader`, third-party Anthropic-compatible endpoints use `x-api-key` by default. `apiKey` can name any environment variable or use a literal or `!command`; it does not need to be named `ANTHROPIC_AUTH_TOKEN`. + +The same option works when redirecting an existing provider without replacing its models: + +```typescript +dreb.registerProvider("anthropic", { + baseUrl: "https://ai.example.com/anthropic", + apiKey: "COMPANY_ANTHROPIC_TOKEN", + authHeader: true +}); +``` + +For custom `streamSimple` implementations with a configured `apiKey`, dreb also exposes its load-time resolved Bearer header through `model.headers`; the custom implementation remains responsible for applying those headers or using `model.authMode` with the request-time `options.apiKey`. + ## OAuth Support Add OAuth/SSO authentication that integrates with `/login`: @@ -544,7 +558,7 @@ interface ProviderConfig { /** Custom headers to include in requests. Values can be env var names. */ headers?: Record; - /** If true, adds Authorization: Bearer header with the resolved API key. */ + /** Use Authorization: Bearer with the resolved API key instead of provider API-key auth. */ authHeader?: boolean; /** Models to register. If provided, replaces all existing models for this provider. */ diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 76481379..1d984a02 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1338,7 +1338,7 @@ dreb.registerProvider("corporate-ai", { - `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. - `headers` - Custom headers to include in requests. -- `authHeader` - If true, adds `Authorization: Bearer` header automatically. +- `authHeader` - Use the resolved API key as `Authorization: Bearer`; for built-in `anthropic-messages`, this replaces `x-api-key` rather than sending both. See [Custom Providers](custom-provider.md#bearer-auth). - `models` - Array of model definitions. If provided, replaces all existing models for this provider. - `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu. - `streamSimple` - Custom streaming implementation for non-standard APIs. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index a1d4927e..eaffdabd 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -110,7 +110,7 @@ Set `api` at provider level (default for all models) or model level (override pe | `api` | API type (see above) | | `apiKey` | API key (see value resolution below) | | `headers` | Custom headers (see value resolution below) | -| `authHeader` | Set `true` to add `Authorization: Bearer ` automatically | +| `authHeader` | Set `true` to use the resolved `apiKey` as `Authorization: Bearer` instead of provider API-key auth | | `models` | Array of model configurations | | `modelOverrides` | Per-model overrides for built-in models on this provider | @@ -132,6 +132,44 @@ The `apiKey` and `headers` fields support three formats: "apiKey": "sk-..." ``` +### Bearer Auth for Anthropic-Compatible Providers + +Third-party Anthropic-compatible endpoints use `x-api-key` by default. If an endpoint instead requires `Authorization: Bearer `, set `authHeader: true`: + +```json +{ + "providers": { + "company-anthropic": { + "baseUrl": "https://ai.example.com/anthropic", + "api": "anthropic-messages", + "apiKey": "COMPANY_ANTHROPIC_TOKEN", + "authHeader": true, + "models": [ + { "id": "company-claude" } + ] + } + } +} +``` + +For the built-in `anthropic-messages` implementation, this selects Bearer-only auth: dreb sends the request-time resolved credential as `Authorization` and does not also send `x-api-key`. The credential can use any [value resolution](#value-resolution) format; the environment variable does not need a special Anthropic SDK name. + +The flag also works when redirecting a built-in provider without redefining its models: + +```json +{ + "providers": { + "anthropic": { + "baseUrl": "https://ai.example.com/anthropic", + "apiKey": "COMPANY_ANTHROPIC_TOKEN", + "authHeader": true + } + } +} +``` + +Leave `authHeader` unset or `false` for endpoints that expect `x-api-key`. + ### Custom Headers ```json diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts index cc5d740c..9e2932ad 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts @@ -6,6 +6,7 @@ * - Custom streamSimple implementation * - OAuth support for /login * - API key support via environment variable + * - Explicit SDK API-key versus Bearer auth selection * - Two model definitions * * Usage: @@ -359,9 +360,11 @@ function streamCustomAnthropic( try { const apiKey = options?.apiKey ?? ""; - const isOAuth = isOAuthToken(apiKey); + const configuredBearer = model.authMode === "bearer"; + const isOAuth = !configuredBearer && isOAuthToken(apiKey); - // Configure client based on auth type + // Configure exactly one SDK auth channel. Explicitly nulling the other + // prevents the SDK from injecting ANTHROPIC_AUTH_TOKEN from the environment. const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14"]; const clientOptions: any = { baseURL: model.baseUrl, @@ -379,7 +382,8 @@ function streamCustomAnthropic( "x-app": "cli", }; } else { - clientOptions.apiKey = apiKey; + clientOptions.apiKey = configuredBearer ? null : apiKey; + clientOptions.authToken = configuredBearer ? apiKey : null; clientOptions.defaultHeaders = { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index b85dd9de..e538ed1f 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.45.3", + "version": "2.45.4", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 536df356..8c730bac 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1250,7 +1250,7 @@ export interface ProviderConfig { streamSimple?: (model: Model, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; /** Custom headers to include in requests. */ headers?: Record; - /** If true, adds Authorization: Bearer header with the resolved API key. */ + /** Use Authorization: Bearer with the resolved API key instead of the provider's API-key auth channel. */ authHeader?: boolean; /** Models to register. If provided, replaces all existing models for this provider. */ models?: ProviderModelConfig[]; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index dfaba66d..4ae2b6f1 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -143,12 +143,13 @@ ajv.addSchema(ModelsConfigSchema, "ModelsConfig"); type ModelsConfig = Static; -/** Provider override config (baseUrl, headers, apiKey, compat) without custom models */ +/** Provider override config without custom models. */ interface ProviderOverride { baseUrl?: string; headers?: Record; apiKey?: string; compat?: Model["compat"]; + authHeader?: boolean; } /** Result of loading custom models from models.json */ @@ -165,6 +166,42 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult { return { models: [], overrides: new Map(), modelOverrides: new Map(), error }; } +function withoutHeader( + headers: Record | undefined, + headerName: string, +): Record | undefined { + if (!headers) return undefined; + const filtered = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.toLowerCase() !== headerName.toLowerCase()), + ); + return Object.keys(filtered).length > 0 ? filtered : undefined; +} + +/** + * Apply provider-level auth configuration while preserving the materialized + * Authorization header used by custom stream implementations. Built-in + * providers can use authMode with the request-time credential instead. + */ +function applyProviderAuthConfig( + headers: Record | undefined, + authHeader: boolean | undefined, + apiKey: string | undefined, + existingAuthMode?: Model["authMode"], +): Pick, "headers" | "authMode"> { + if (authHeader === undefined) { + return { headers, authMode: existingAuthMode }; + } + if (!authHeader) { + return { headers, authMode: undefined }; + } + + const resolvedKey = apiKey ? resolveConfigValue(apiKey) : undefined; + return { + headers: resolvedKey ? { ...headers, Authorization: `Bearer ${resolvedKey}` } : headers, + authMode: "bearer", + }; +} + function mergeCompat( baseCompat: Model["compat"], overrideCompat: ModelOverride["compat"], @@ -327,13 +364,20 @@ export class ModelRegistry { return models.map((m) => { let model = m; - // Apply provider-level baseUrl/headers/compat override + // Apply provider-level baseUrl/headers/auth/compat override if (providerOverride) { const resolvedHeaders = resolveHeaders(providerOverride.headers); + const mergedHeaders = resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers; + const authConfig = applyProviderAuthConfig( + mergedHeaders, + providerOverride.authHeader, + providerOverride.apiKey, + model.authMode, + ); model = { ...model, + ...authConfig, baseUrl: providerOverride.baseUrl ?? model.baseUrl, - headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers, compat: mergeCompat(model.compat, providerOverride.compat), }; } @@ -388,13 +432,20 @@ export class ModelRegistry { const modelOverrides = new Map>(); for (const [providerName, providerConfig] of Object.entries(config.providers)) { - // Apply provider-level baseUrl/headers/apiKey/compat override to built-in models when configured. - if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey || providerConfig.compat) { + // Apply provider-level overrides to built-in models when configured. + if ( + providerConfig.baseUrl || + providerConfig.headers || + providerConfig.apiKey || + providerConfig.compat || + providerConfig.authHeader !== undefined + ) { overrides.set(providerName, { baseUrl: providerConfig.baseUrl, headers: providerConfig.headers, apiKey: providerConfig.apiKey, compat: providerConfig.compat, + authHeader: providerConfig.authHeader, }); } @@ -483,15 +534,8 @@ export class ModelRegistry { const providerHeaders = resolveHeaders(providerConfig.headers); const modelHeaders = resolveHeaders(modelDef.headers); const compat = mergeCompat(providerConfig.compat, modelDef.compat); - let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined; - - // If authHeader is true, add Authorization header with resolved API key - if (providerConfig.authHeader && providerConfig.apiKey) { - const resolvedKey = resolveConfigValue(providerConfig.apiKey); - if (resolvedKey) { - headers = { ...headers, Authorization: `Bearer ${resolvedKey}` }; - } - } + const headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined; + const authConfig = applyProviderAuthConfig(headers, providerConfig.authHeader, providerConfig.apiKey); // Provider baseUrl is required when custom models are defined. // Individual models can override it with modelDef.baseUrl. @@ -507,7 +551,7 @@ export class ModelRegistry { cost: modelDef.cost ?? defaultCost, contextWindow: modelDef.contextWindow ?? 128000, maxTokens: modelDef.maxTokens ?? 16384, - headers, + ...authConfig, compat, } as Model); } @@ -650,18 +694,11 @@ export class ModelRegistry { for (const modelDef of config.models) { const api = modelDef.api || config.api; - // Merge headers + // Merge headers and provider-level auth configuration const providerHeaders = resolveHeaders(config.headers); const modelHeaders = resolveHeaders(modelDef.headers); - let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined; - - // If authHeader is true, add Authorization header - if (config.authHeader && config.apiKey) { - const resolvedKey = resolveConfigValue(config.apiKey); - if (resolvedKey) { - headers = { ...headers, Authorization: `Bearer ${resolvedKey}` }; - } - } + const headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined; + const authConfig = applyProviderAuthConfig(headers, config.authHeader, config.apiKey); this.models.push({ id: modelDef.id, @@ -674,7 +711,7 @@ export class ModelRegistry { cost: modelDef.cost, contextWindow: modelDef.contextWindow, maxTokens: modelDef.maxTokens, - headers, + ...authConfig, compat: modelDef.compat, } as Model); } @@ -687,14 +724,20 @@ export class ModelRegistry { } } } else if (config.baseUrl) { - // Override-only: update baseUrl/headers for existing models + // Override-only: update baseUrl/headers/auth for existing models const resolvedHeaders = resolveHeaders(config.headers); this.models = this.models.map((m) => { if (m.provider !== providerName) return m; + const baseHeaders = + config.authHeader === false && m.authMode === "bearer" + ? withoutHeader(m.headers, "authorization") + : m.headers; + const headers = resolvedHeaders ? { ...baseHeaders, ...resolvedHeaders } : baseHeaders; + const authConfig = applyProviderAuthConfig(headers, config.authHeader, config.apiKey, m.authMode); return { ...m, + ...authConfig, baseUrl: config.baseUrl ?? m.baseUrl, - headers: resolvedHeaders ? { ...m.headers, ...resolvedHeaders } : m.headers, }; }); } @@ -710,6 +753,7 @@ export interface ProviderConfigInput { api?: Api; streamSimple?: (model: Model, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; headers?: Record; + /** Use Authorization: Bearer with the resolved API key instead of the provider's API-key auth channel. */ authHeader?: boolean; /** OAuth provider for /login support */ oauth?: Omit; diff --git a/packages/coding-agent/test/anthropic-auth-integration.test.ts b/packages/coding-agent/test/anthropic-auth-integration.test.ts new file mode 100644 index 00000000..9228181f --- /dev/null +++ b/packages/coding-agent/test/anthropic-auth-integration.test.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type Context, streamSimple } from "@dreb/ai"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { ModelRegistry } from "../src/core/model-registry.js"; + +const originalFetch = global.fetch; +const tempDirs: string[] = []; + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], +}; + +function createSseResponse(): Response { + const events = [ + { + event: "message_start", + data: { + type: "message_start", + message: { + id: "msg_test", + type: "message", + role: "assistant", + model: "test-model", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + }, + { + event: "message_delta", + data: { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + { event: "message_stop", data: { type: "message_stop" } }, + ]; + const body = `${events.map(({ event, data }) => `event: ${event}\ndata: ${JSON.stringify(data)}`).join("\n\n")}\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + if (existsSync(dir)) rmSync(dir, { recursive: true }); + } +}); + +describe("Anthropic registry auth integration", () => { + test("sends the AuthStorage runtime Bearer credential instead of the materialized fallback", async () => { + const tempDir = join(tmpdir(), `dreb-test-anthropic-auth-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDirs.push(tempDir); + mkdirSync(tempDir, { recursive: true }); + const modelsJsonPath = join(tempDir, "models.json"); + writeFileSync( + modelsJsonPath, + JSON.stringify({ + providers: { + "custom-bearer": { + baseUrl: "https://proxy.example.com", + api: "anthropic-messages", + apiKey: "load-time-fallback-key", + authHeader: true, + models: [ + { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }, + }, + }), + ); + + const authStorage = AuthStorage.create(join(tempDir, "auth.json")); + authStorage.setRuntimeApiKey("custom-bearer", "runtime-key"); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const model = registry.find("custom-bearer", "test-model"); + expect(model).toBeDefined(); + if (!model) throw new Error("Expected custom Bearer model"); + expect(model.authMode).toBe("bearer"); + expect(model.headers?.Authorization).toBe("Bearer load-time-fallback-key"); + + const apiKey = await registry.getApiKey(model); + expect(apiKey).toBe("runtime-key"); + + let captured: Headers | undefined; + global.fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + captured = new Headers(init?.headers); + return createSseResponse(); + }) as typeof fetch; + + const stream = streamSimple(model, context, { apiKey }); + const errors: string[] = []; + for await (const event of stream) { + if (event.type === "error") errors.push(event.error.errorMessage ?? "unknown error"); + } + + expect(errors).toEqual([]); + expect(captured).toBeDefined(); + expect(captured?.get("authorization")).toBe("Bearer runtime-key"); + expect(captured?.has("x-api-key")).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 6ae33ba8..0e5eb564 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -466,6 +466,169 @@ describe("ModelRegistry", () => { }); }); + describe("authHeader", () => { + test("custom model uses Bearer mode with an arbitrary environment variable", async () => { + const envName = "DREB_TEST_CUSTOM_BEARER_TOKEN"; + const original = process.env[envName]; + process.env[envName] = "env-bearer-key"; + + try { + writeRawModelsJson({ + "custom-bearer": { + ...providerConfig("https://proxy.example.com", [{ id: "bearer-model" }]), + apiKey: envName, + authHeader: true, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const model = registry.find("custom-bearer", "bearer-model"); + + expect(model?.authMode).toBe("bearer"); + expect(model?.headers?.Authorization).toBe("Bearer env-bearer-key"); + expect(await registry.getApiKeyForProvider("custom-bearer")).toBe("env-bearer-key"); + } finally { + if (original === undefined) delete process.env[envName]; + else process.env[envName] = original; + } + }); + + test("custom models preserve literal and command-backed Bearer credentials", async () => { + writeRawModelsJson({ + "literal-bearer": { + ...providerConfig("https://literal.example.com", [{ id: "literal-model" }]), + apiKey: "literal-bearer-key", + authHeader: true, + }, + "command-bearer": { + ...providerConfig("https://command.example.com", [{ id: "command-model" }]), + apiKey: "!printf command-bearer-key", + authHeader: true, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const literal = registry.find("literal-bearer", "literal-model"); + const command = registry.find("command-bearer", "command-model"); + + expect(literal?.authMode).toBe("bearer"); + expect(literal?.headers?.Authorization).toBe("Bearer literal-bearer-key"); + expect(await registry.getApiKeyForProvider("literal-bearer")).toBe("literal-bearer-key"); + expect(command?.authMode).toBe("bearer"); + expect(command?.headers?.Authorization).toBe("Bearer command-bearer-key"); + expect(await registry.getApiKeyForProvider("command-bearer")).toBe("command-bearer-key"); + }); + + test("false or absent authHeader preserves the provider default", () => { + writeRawModelsJson({ + "explicit-default": { + ...providerConfig("https://default.example.com", [{ id: "explicit-model" }]), + authHeader: false, + }, + "implicit-default": providerConfig("https://implicit.example.com", [{ id: "implicit-model" }]), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + for (const model of [ + registry.find("explicit-default", "explicit-model"), + registry.find("implicit-default", "implicit-model"), + ]) { + expect(model?.authMode).toBeUndefined(); + expect(model?.headers?.Authorization).toBeUndefined(); + } + }); + + test("models.json applies Bearer mode to override-only built-in providers", () => { + writeRawModelsJson({ + anthropic: { + baseUrl: "https://bearer-proxy.example.com", + apiKey: "override-bearer-key", + authHeader: true, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "anthropic"); + + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.baseUrl).toBe("https://bearer-proxy.example.com"); + expect(model.authMode).toBe("bearer"); + expect(model.headers?.Authorization).toBe("Bearer override-bearer-key"); + } + }); + + test("refresh clears Bearer mode when authHeader is disabled", () => { + writeRawModelsJson({ + anthropic: { + baseUrl: "https://bearer-proxy.example.com", + apiKey: "override-bearer-key", + authHeader: true, + }, + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(getModelsForProvider(registry, "anthropic")[0].authMode).toBe("bearer"); + + writeRawModelsJson({ + anthropic: { + baseUrl: "https://api-key-proxy.example.com", + apiKey: "override-api-key", + authHeader: false, + }, + }); + registry.refresh(); + + for (const model of getModelsForProvider(registry, "anthropic")) { + expect(model.authMode).toBeUndefined(); + expect(model.headers?.Authorization).toBeUndefined(); + } + }); + + test("registerProvider applies Bearer mode to custom and override-only providers", () => { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + registry.registerProvider("dynamic-bearer", { + baseUrl: "https://dynamic.example.com", + apiKey: "dynamic-bearer-key", + api: "anthropic-messages", + authHeader: true, + models: [ + { + id: "dynamic-model", + name: "Dynamic Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }); + registry.registerProvider("anthropic", { + baseUrl: "https://dynamic-override.example.com", + apiKey: "dynamic-override-key", + authHeader: true, + }); + + const custom = registry.find("dynamic-bearer", "dynamic-model"); + expect(custom?.authMode).toBe("bearer"); + expect(custom?.headers?.Authorization).toBe("Bearer dynamic-bearer-key"); + for (const model of getModelsForProvider(registry, "anthropic")) { + expect(model.authMode).toBe("bearer"); + expect(model.headers?.Authorization).toBe("Bearer dynamic-override-key"); + } + + registry.registerProvider("anthropic", { + baseUrl: "https://dynamic-api-key.example.com", + apiKey: "dynamic-api-key", + authHeader: false, + }); + for (const model of getModelsForProvider(registry, "anthropic")) { + expect(model.authMode).toBeUndefined(); + expect(model.headers?.Authorization).toBeUndefined(); + } + }); + }); + // Dynamically find a provider with ≥2 built-in models for override tests. // This avoids hardcoding provider/model IDs that may disappear from the generated registry. const testProvider = getProviders().find((p) => getModels(p).length >= 2)!; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index a7080898..9f807e6d 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/dashboard", - "version": "2.45.3", + "version": "2.45.4", "description": "Web dashboard for dreb — fleet overview, chat parity, subagent observability", "license": "MIT", "type": "module", diff --git a/packages/semantic-search/.claude-plugin/plugin.json b/packages/semantic-search/.claude-plugin/plugin.json index e4e20912..1645c31f 100644 --- a/packages/semantic-search/.claude-plugin/plugin.json +++ b/packages/semantic-search/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "semantic-search", "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking", - "version": "2.45.3", + "version": "2.45.4", "author": { "name": "Drew Brereton" }, diff --git a/packages/semantic-search/package.json b/packages/semantic-search/package.json index 48d2c748..3150789e 100644 --- a/packages/semantic-search/package.json +++ b/packages/semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/semantic-search", - "version": "2.45.3", + "version": "2.45.4", "description": "Semantic codebase search engine with embedding-based ranking and MCP server", "publishConfig": { "access": "public" diff --git a/packages/telegram/package.json b/packages/telegram/package.json index fd8cd2bf..1f0ae35a 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/telegram", - "version": "2.45.3", + "version": "2.45.4", "description": "Telegram bot frontend for dreb coding agent", "license": "MIT", "type": "module", diff --git a/packages/tui/package.json b/packages/tui/package.json index a5710d86..a256f9a9 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/tui", - "version": "2.45.3", + "version": "2.45.4", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js",