Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
35 changes: 26 additions & 9 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,17 @@ function mergeHeaders(...headerSources: (Record<string, string> | undefined)[]):
return merged;
}

function withoutHeader(
headers: Record<string, string> | undefined,
headerName: string,
): Record<string, string> | 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,
Expand Down Expand Up @@ -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") {
Expand All @@ -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,
),
Expand All @@ -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,
),
});
Expand All @@ -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,
Expand All @@ -611,17 +627,18 @@ function createClient(
"user-agent": `claude-cli/${claudeCodeVersion}`,
"x-app": "cli",
},
model.headers,
modelHeaders,
optionsHeaders,
),
});

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(
Expand All @@ -630,7 +647,7 @@ function createClient(
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-beta": betaFeatures.join(","),
},
model.headers,
modelHeaders,
optionsHeaders,
),
});
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -343,6 +346,8 @@ export interface Model<TApi extends Api> {
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
/** 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
Expand Down
143 changes: 143 additions & 0 deletions packages/ai/test/anthropic-auth-headers.test.ts
Original file line number Diff line number Diff line change
@@ -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">> = {},
): 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<Headers> {
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);
});
});
Loading
Loading