Skip to content
Open
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
33 changes: 33 additions & 0 deletions examples/profile-routing-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { startProxyServer } from "../src/proxy/server"

const port = Number.parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10)
const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1"

const personalDir = process.env.MERIDIAN_PERSONAL_CLAUDE_DIR
const companyDir = process.env.MERIDIAN_COMPANY_CLAUDE_DIR

if (!personalDir || !companyDir) {
throw new Error("Set MERIDIAN_PERSONAL_CLAUDE_DIR and MERIDIAN_COMPANY_CLAUDE_DIR before running this example")
}

const proxy = await startProxyServer({
port,
host,
profiles: [
{ id: "personal", claudeConfigDir: personalDir },
{ id: "company", claudeConfigDir: companyDir },
],
defaultProfile: "personal",
})

const stop = async () => {
await proxy.close()
process.exit(0)
}

process.on("SIGINT", () => { void stop() })
process.on("SIGTERM", () => { void stop() })

console.log(`Profile test proxy running at http://${host}:${port}`)
console.log("Profiles: personal, company")
console.log("Use x-meridian-profile to select a profile per request")
9 changes: 9 additions & 0 deletions src/__tests__/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ describe("openCodeAdapter", () => {
expect(openCodeAdapter.getSessionId(mockContext as any)).toBeUndefined()
})

it("extracts profile ID from x-meridian-profile header", () => {
const mockContext = {
req: {
header: (name: string) => name === "x-meridian-profile" ? "company" : undefined
}
}
expect(openCodeAdapter.getProfileId(mockContext as any)).toBe("company")
})

it("extracts working directory from system prompt env block", () => {
const body = {
system: "<env>\n Working directory: /Users/test/project\n</env>"
Expand Down
164 changes: 164 additions & 0 deletions src/__tests__/proxy-profiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"

let capturedQueryOptions: any[] = []

mock.module("@anthropic-ai/claude-agent-sdk", () => ({
query: (params: any) => {
capturedQueryOptions.push(params.options)
const profileDir = params.options?.env?.CLAUDE_CONFIG_DIR
const sessionSuffix = typeof profileDir === "string"
? profileDir.split("/").at(-1)
: params.options?.env?.ANTHROPIC_API_KEY
? "api"
: "default"

return (async function* () {
yield {
type: "assistant",
message: {
id: `msg-${sessionSuffix}`,
type: "message",
role: "assistant",
content: [{ type: "text", text: `ok-${sessionSuffix}` }],
model: "claude-sonnet-4-5",
stop_reason: "end_turn",
usage: { input_tokens: 10, output_tokens: 5 },
},
session_id: `sdk-session-${sessionSuffix}`,
}
})()
},
createSdkMcpServer: () => ({ type: "sdk", name: "test", instance: {} }),
}))

mock.module("../logger", () => ({
claudeLog: () => {},
withClaudeLogContext: (_ctx: any, fn: any) => fn(),
}))

mock.module("../mcpTools", () => ({
createOpencodeMcpServer: () => ({ type: "sdk", name: "opencode", instance: {} }),
}))

mock.module("../proxy/models", () => ({
mapModelToClaudeModel: () => "sonnet",
resolveClaudeExecutableAsync: async () => "/usr/bin/claude",
isClosedControllerError: () => false,
getClaudeAuthStatusAsync: async () => ({ loggedIn: true, subscriptionType: "max" }),
hasExtendedContext: () => false,
stripExtendedContext: (model: string) => model,
}))

const { createProxyServer, clearSessionCache } = await import("../proxy/server")

function createTestApp(config: Record<string, any> = {}) {
const { app } = createProxyServer({ port: 0, host: "127.0.0.1", ...config })
return app
}

async function post(app: any, body: any, headers: Record<string, string> = {}) {
return app.fetch(new Request("http://localhost/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
}))
}

const BASIC_REQUEST = {
model: "claude-sonnet-4-5",
max_tokens: 128,
stream: false,
messages: [{ role: "user", content: "hello" }],
}

describe("Profile routing", () => {
const savedEnv: Record<string, string | undefined> = {}

beforeEach(() => {
capturedQueryOptions = []
clearSessionCache()
for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"]) {
savedEnv[key] = process.env[key]
}
})

afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
})

it("passes CLAUDE_CONFIG_DIR for a claude-max profile", async () => {
const app = createTestApp({
profiles: [{ id: "company", claudeConfigDir: "/tmp/company-profile" }],
defaultProfile: "company",
})

await (await post(app, BASIC_REQUEST)).json()

expect(capturedQueryOptions[0]?.env?.CLAUDE_CONFIG_DIR).toBe("/tmp/company-profile")
})

it("overlays API profile auth env after inherited Anthropic vars are stripped", async () => {
process.env.ANTHROPIC_API_KEY = "dummy-key"
process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:3456"
process.env.ANTHROPIC_AUTH_TOKEN = "dummy-token"

const app = createTestApp({
profiles: [{
id: "api-direct",
type: "api",
apiKey: "real-key",
baseUrl: "https://api.example.test",
authToken: "real-token",
}],
defaultProfile: "api-direct",
})

await (await post(app, BASIC_REQUEST)).json()

expect(capturedQueryOptions[0]?.env?.ANTHROPIC_API_KEY).toBe("real-key")
expect(capturedQueryOptions[0]?.env?.ANTHROPIC_BASE_URL).toBe("https://api.example.test")
expect(capturedQueryOptions[0]?.env?.ANTHROPIC_AUTH_TOKEN).toBe("real-token")
})

it("keeps session resume isolated by requested profile", async () => {
const app = createTestApp({
profiles: [
{ id: "personal", claudeConfigDir: "/tmp/personal-profile" },
{ id: "company", claudeConfigDir: "/tmp/company-profile" },
],
defaultProfile: "personal",
})

await (await post(app, BASIC_REQUEST, {
"x-opencode-session": "shared-session",
"x-meridian-profile": "personal",
})).json()

await (await post(app, BASIC_REQUEST, {
"x-opencode-session": "shared-session",
"x-meridian-profile": "company",
})).json()

await (await post(app, {
...BASIC_REQUEST,
messages: [
{ role: "user", content: "hello" },
{ role: "assistant", content: [{ type: "text", text: "ok-personal-profile" }] },
{ role: "user", content: "remember me" },
],
}, {
"x-opencode-session": "shared-session",
"x-meridian-profile": "personal",
})).json()

expect(capturedQueryOptions[0]?.resume).toBeUndefined()
expect(capturedQueryOptions[1]?.resume).toBeUndefined()
expect(capturedQueryOptions[2]?.resume).toBe("sdk-session-personal-profile")
})
})
1 change: 1 addition & 0 deletions src/proxy/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AgentAdapter {
* Returns undefined if the agent doesn't provide session tracking.
*/
getSessionId(c: Context): string | undefined
getProfileId(c: Context): string | undefined

/**
* Extract the client's working directory from the request body.
Expand Down
4 changes: 4 additions & 0 deletions src/proxy/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const openCodeAdapter: AgentAdapter = {
return c.req.header("x-opencode-session")
},

getProfileId(c: Context): string | undefined {
return c.req.header("x-meridian-profile")
},

extractWorkingDirectory(body: any): string | undefined {
return extractClientCwd(body)
},
Expand Down
78 changes: 61 additions & 17 deletions src/proxy/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,41 @@ let cachedAuthStatusAt = 0
let cachedAuthStatusIsFailure = false
let cachedAuthStatusPromise: Promise<ClaudeAuthStatus | null> | null = null

type AuthStatusCache = {
cachedAuthStatus: ClaudeAuthStatus | null
lastKnownGoodAuthStatus: ClaudeAuthStatus | null
cachedAuthStatusAt: number
cachedAuthStatusIsFailure: boolean
cachedAuthStatusPromise: Promise<ClaudeAuthStatus | null> | null
}

const authStatusCacheByKey = new Map<string, AuthStatusCache>()

function getAuthStatusCacheKey(envOverrides?: Record<string, string | undefined>): string {
if (!envOverrides) return "default"
return JSON.stringify({
CLAUDE_CONFIG_DIR: envOverrides.CLAUDE_CONFIG_DIR,
ANTHROPIC_API_KEY: envOverrides.ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL: envOverrides.ANTHROPIC_BASE_URL,
ANTHROPIC_AUTH_TOKEN: envOverrides.ANTHROPIC_AUTH_TOKEN,
})
}

function getAuthStatusCache(key: string): AuthStatusCache {
let cache = authStatusCacheByKey.get(key)
if (!cache) {
cache = {
cachedAuthStatus,
lastKnownGoodAuthStatus,
cachedAuthStatusAt,
cachedAuthStatusIsFailure,
cachedAuthStatusPromise,
}
authStatusCacheByKey.set(key, cache)
}
return cache
}

/**
* Only Claude 4.6 models support the 1M extended context window.
* Older models (4.5 and earlier) do not.
Expand Down Expand Up @@ -72,39 +107,43 @@ export function hasExtendedContext(model: ClaudeModel): boolean {
return model.endsWith("[1m]")
}

export async function getClaudeAuthStatusAsync(): Promise<ClaudeAuthStatus | null> {
export async function getClaudeAuthStatusAsync(envOverrides?: Record<string, string | undefined>): Promise<ClaudeAuthStatus | null> {
const cache = getAuthStatusCache(getAuthStatusCacheKey(envOverrides))
// Return cached result if within TTL — use shorter TTL for failures to recover faster
const ttl = cachedAuthStatusIsFailure ? AUTH_STATUS_FAILURE_TTL_MS : AUTH_STATUS_CACHE_TTL_MS
if (cachedAuthStatusAt > 0 && Date.now() - cachedAuthStatusAt < ttl) {
const ttl = cache.cachedAuthStatusIsFailure ? AUTH_STATUS_FAILURE_TTL_MS : AUTH_STATUS_CACHE_TTL_MS
if (cache.cachedAuthStatusAt > 0 && Date.now() - cache.cachedAuthStatusAt < ttl) {
// On failure, return last known good status (preserves subscription type for model selection)
return cachedAuthStatus ?? lastKnownGoodAuthStatus
return cache.cachedAuthStatus ?? cache.lastKnownGoodAuthStatus
}
if (cachedAuthStatusPromise) return cachedAuthStatusPromise
if (cache.cachedAuthStatusPromise) return cache.cachedAuthStatusPromise

cachedAuthStatusPromise = (async () => {
cache.cachedAuthStatusPromise = (async () => {
try {
const { stdout } = await exec("claude auth status", { timeout: 5000 })
const { stdout } = await exec("claude auth status", {
timeout: 5000,
env: { ...process.env, ...envOverrides },
})
const parsed = JSON.parse(stdout) as ClaudeAuthStatus
cachedAuthStatus = parsed
lastKnownGoodAuthStatus = parsed
cachedAuthStatusAt = Date.now()
cachedAuthStatusIsFailure = false
cache.cachedAuthStatus = parsed
cache.lastKnownGoodAuthStatus = parsed
cache.cachedAuthStatusAt = Date.now()
cache.cachedAuthStatusIsFailure = false
return parsed
} catch {
// Short-lived negative cache: retry in 5s instead of 60s.
// Return last known good status so model selection doesn't degrade
// (e.g. sonnet[1m] → sonnet) during transient auth command failures.
cachedAuthStatusIsFailure = true
cachedAuthStatusAt = Date.now()
cachedAuthStatus = null
return lastKnownGoodAuthStatus
cache.cachedAuthStatusIsFailure = true
cache.cachedAuthStatusAt = Date.now()
cache.cachedAuthStatus = null
return cache.lastKnownGoodAuthStatus
}
})()

try {
return await cachedAuthStatusPromise
return await cache.cachedAuthStatusPromise
} finally {
cachedAuthStatusPromise = null
cache.cachedAuthStatusPromise = null
}
}

Expand Down Expand Up @@ -172,6 +211,7 @@ export function resetCachedClaudeAuthStatus(): void {
cachedAuthStatusAt = 0
cachedAuthStatusIsFailure = false
cachedAuthStatusPromise = null
authStatusCacheByKey.clear()
}

/** Expire the auth status cache without clearing lastKnownGoodAuthStatus — for testing only.
Expand All @@ -180,6 +220,10 @@ export function resetCachedClaudeAuthStatus(): void {
export function expireAuthStatusCache(): void {
cachedAuthStatusAt = 0
cachedAuthStatusPromise = null
for (const cache of authStatusCacheByKey.values()) {
cache.cachedAuthStatusAt = 0
cache.cachedAuthStatusPromise = null
}
}

/**
Expand Down
Loading