From d6c774336ba59774bd4fadc7563f456e5adf991c Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:27:22 -0500 Subject: [PATCH 1/4] feat: add profile routing config --- src/proxy/adapter.ts | 1 + src/proxy/adapters/opencode.ts | 4 +++ src/proxy/profiles.ts | 61 ++++++++++++++++++++++++++++++++++ src/proxy/types.ts | 18 ++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/proxy/profiles.ts diff --git a/src/proxy/adapter.ts b/src/proxy/adapter.ts index 5ae6878..ccd4d56 100644 --- a/src/proxy/adapter.ts +++ b/src/proxy/adapter.ts @@ -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. diff --git a/src/proxy/adapters/opencode.ts b/src/proxy/adapters/opencode.ts index eae66b9..bddbd65 100644 --- a/src/proxy/adapters/opencode.ts +++ b/src/proxy/adapters/opencode.ts @@ -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) }, diff --git a/src/proxy/profiles.ts b/src/proxy/profiles.ts new file mode 100644 index 0000000..788e768 --- /dev/null +++ b/src/proxy/profiles.ts @@ -0,0 +1,61 @@ +import type { ProfileConfig, ProfileType, ProxyConfig } from "./types" + +export const DEFAULT_PROFILE_ID = "default" + +export interface ResolvedProfile { + id: string + type: ProfileType + claudeExecutable?: string + env: Record +} + +function getProfileType(profile: ProfileConfig): ProfileType { + return profile.type ?? "claude-max" +} + +function getEnvValue(directValue: string | undefined, envName: string | undefined): string | undefined { + if (directValue) return directValue + if (!envName) return undefined + return process.env[envName] +} + +function buildProfileEnv(profile: ProfileConfig): Record { + const type = getProfileType(profile) + + if (type === "api") { + return { + ANTHROPIC_API_KEY: getEnvValue(profile.apiKey, profile.apiKeyEnv), + ANTHROPIC_BASE_URL: profile.baseUrl, + ANTHROPIC_AUTH_TOKEN: getEnvValue(profile.authToken, profile.authTokenEnv), + } + } + + return { + ...(profile.claudeConfigDir ? { CLAUDE_CONFIG_DIR: profile.claudeConfigDir } : {}), + } +} + +export function resolveProfile(config: ProxyConfig, requestedProfileId?: string): ResolvedProfile { + const configuredProfiles = config.profiles ?? [] + + if (configuredProfiles.length === 0) { + return { + id: requestedProfileId ?? config.defaultProfile ?? DEFAULT_PROFILE_ID, + type: "claude-max", + env: {}, + } + } + + const resolvedId = requestedProfileId ?? config.defaultProfile ?? configuredProfiles[0]!.id + const profile = configuredProfiles.find((candidate) => candidate.id === resolvedId) + if (!profile) { + throw new Error(`Unknown profile: ${resolvedId}`) + } + + return { + id: profile.id, + type: getProfileType(profile), + claudeExecutable: profile.claudeExecutable, + env: buildProfileEnv(profile), + } +} diff --git a/src/proxy/types.ts b/src/proxy/types.ts index 8e51ec8..52da5c7 100644 --- a/src/proxy/types.ts +++ b/src/proxy/types.ts @@ -6,6 +6,22 @@ export interface ProxyConfig { debug: boolean idleTimeoutSeconds: number silent: boolean + profiles?: ProfileConfig[] + defaultProfile?: string +} + +export type ProfileType = "claude-max" | "api" + +export interface ProfileConfig { + id: string + type?: ProfileType + claudeConfigDir?: string + claudeExecutable?: string + apiKey?: string + apiKeyEnv?: string + baseUrl?: string + authToken?: string + authTokenEnv?: string } export interface ProxyInstance { @@ -31,4 +47,6 @@ export const DEFAULT_PROXY_CONFIG: ProxyConfig = { debug: process.env.CLAUDE_PROXY_DEBUG === "1", idleTimeoutSeconds: 120, silent: false, + profiles: undefined, + defaultProfile: undefined, } From 286fedc0d99468791a722da441e68ef8008d20aa Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:27:45 -0500 Subject: [PATCH 2/4] refactor: scope sessions and runtime by profile --- src/proxy/models.ts | 78 ++++++++++++++++++++++++++++-------- src/proxy/server.ts | 48 ++++++++++++---------- src/proxy/session/cache.ts | 71 ++++++++++++++++++++++---------- src/proxy/session/lineage.ts | 1 + src/proxy/sessionStore.ts | 12 +++++- 5 files changed, 148 insertions(+), 62 deletions(-) diff --git a/src/proxy/models.ts b/src/proxy/models.ts index edab596..40cf5f3 100644 --- a/src/proxy/models.ts +++ b/src/proxy/models.ts @@ -30,6 +30,41 @@ let cachedAuthStatusAt = 0 let cachedAuthStatusIsFailure = false let cachedAuthStatusPromise: Promise | null = null +type AuthStatusCache = { + cachedAuthStatus: ClaudeAuthStatus | null + lastKnownGoodAuthStatus: ClaudeAuthStatus | null + cachedAuthStatusAt: number + cachedAuthStatusIsFailure: boolean + cachedAuthStatusPromise: Promise | null +} + +const authStatusCacheByKey = new Map() + +function getAuthStatusCacheKey(envOverrides?: Record): 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. @@ -72,39 +107,43 @@ export function hasExtendedContext(model: ClaudeModel): boolean { return model.endsWith("[1m]") } -export async function getClaudeAuthStatusAsync(): Promise { +export async function getClaudeAuthStatusAsync(envOverrides?: Record): Promise { + 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 } } @@ -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. @@ -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 + } } /** diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 90bb239..5c718e3 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -25,6 +25,7 @@ import { ALLOWED_MCP_TOOLS } from "./tools" import { getLastUserMessage } from "./messages" import { openCodeAdapter } from "./adapters/opencode" import { buildQueryOptions, type QueryContext } from "./query" +import { resolveProfile } from "./profiles" import { computeLineageHash, hashMessage, @@ -181,11 +182,18 @@ export function createProxyServer(config: Partial = {}): ProxyServe return withClaudeLogContext({ requestId: requestMeta.requestId, endpoint: requestMeta.endpoint }, async () => { try { const body = await c.req.json() - const authStatus = await getClaudeAuthStatusAsync() - let model = mapModelToClaudeModel(body.model || "sonnet", authStatus?.subscriptionType) const stream = body.stream ?? true const adapter = openCodeAdapter + const requestedProfile = resolveProfile(finalConfig, adapter.getProfileId(c)) const workingDirectory = process.env.CLAUDE_PROXY_WORKDIR || adapter.extractWorkingDirectory(body) || process.cwd() + const opencodeSessionId = adapter.getSessionId(c) + const lineageResult = lookupSession(opencodeSessionId, body.messages || [], workingDirectory, requestedProfile.id) + const isResume = lineageResult.type === "continuation" || lineageResult.type === "compaction" + const isUndo = lineageResult.type === "undo" + const cachedSession = lineageResult.type !== "diverged" ? lineageResult.session : undefined + const effectiveProfile = resolveProfile(finalConfig, cachedSession?.profileId || requestedProfile.id) + const authStatus = await getClaudeAuthStatusAsync(effectiveProfile.env) + let model = mapModelToClaudeModel(body.model || "sonnet", authStatus?.subscriptionType) // Strip env vars that would cause the SDK subprocess to loop back through // the proxy instead of using its native Claude Max auth. Also strip vars @@ -197,6 +205,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe ANTHROPIC_AUTH_TOKEN: _dropAuthToken, ...cleanEnv } = process.env + const runtimeEnv = { ...cleanEnv, ...effectiveProfile.env } + let runtimeClaudeExecutable = effectiveProfile.claudeExecutable || claudeExecutable let systemContext = "" if (body.system) { @@ -210,12 +220,6 @@ export function createProxyServer(config: Partial = {}): ProxyServe } } - // Session resume: look up cached Claude SDK session and classify mutation - const opencodeSessionId = adapter.getSessionId(c) - const lineageResult = lookupSession(opencodeSessionId, body.messages || [], workingDirectory) - const isResume = lineageResult.type === "continuation" || lineageResult.type === "compaction" - const isUndo = lineageResult.type === "undo" - const cachedSession = lineageResult.type !== "diverged" ? lineageResult.session : undefined const resumeSessionId = cachedSession?.claudeSessionId // For undo: fork the session at the rollback point const undoRollbackUuid = isUndo && lineageResult.type === "undo" ? lineageResult.rollbackUuid : undefined @@ -478,8 +482,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe try { // Lazy-resolve executable if not already set (e.g. when using createProxyServer directly) - if (!claudeExecutable) { - claudeExecutable = await resolveClaudeExecutableAsync() + if (!runtimeClaudeExecutable) { + runtimeClaudeExecutable = await resolveClaudeExecutableAsync() } // Wrap SDK call with transparent retry for recoverable errors. @@ -502,8 +506,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe let didYieldContent = false try { for await (const event of query(buildQueryOptions({ - prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv, + prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId, isUndo, undoRollbackUuid, sdkHooks, adapter, }))) { if ((event as any).type === "assistant") { @@ -526,13 +530,13 @@ export function createProxyServer(config: Partial = {}): ProxyServe resumeSessionId, }) console.error(`[PROXY] Stale session UUID, evicting and retrying as fresh session`) - evictSession(opencodeSessionId, workingDirectory, allMessages) + evictSession(opencodeSessionId, workingDirectory, allMessages, requestedProfile.id) sdkUuidMap.length = 0 for (let i = 0; i < allMessages.length; i++) sdkUuidMap.push(null) yield* query(buildQueryOptions({ prompt: buildFreshPrompt(allMessages, stripCacheControl), - model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv, + model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId: undefined, isUndo: false, undoRollbackUuid: undefined, sdkHooks, adapter, })) return @@ -684,7 +688,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Store session for future resume if (currentSessionId) { - storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap) + storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap, requestedProfile.id, effectiveProfile.id) } const responseSessionId = currentSessionId || resumeSessionId || `session_${Date.now()}` @@ -768,8 +772,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe let didYieldClientEvent = false try { for await (const event of query(buildQueryOptions({ - prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv, + prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId, isUndo, undoRollbackUuid, sdkHooks, adapter, }))) { if ((event as any).type === "stream_event") { @@ -792,13 +796,13 @@ export function createProxyServer(config: Partial = {}): ProxyServe resumeSessionId, }) console.error(`[PROXY] Stale session UUID, evicting and retrying as fresh session`) - evictSession(opencodeSessionId, workingDirectory, allMessages) + evictSession(opencodeSessionId, workingDirectory, allMessages, requestedProfile.id) sdkUuidMap.length = 0 for (let i = 0; i < allMessages.length; i++) sdkUuidMap.push(null) yield* query(buildQueryOptions({ prompt: buildFreshPrompt(allMessages, stripCacheControl), - model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv, + model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId: undefined, isUndo: false, undoRollbackUuid: undefined, sdkHooks, adapter, })) return @@ -971,7 +975,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Store session for future resume if (currentSessionId) { - storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap) + storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap, requestedProfile.id, effectiveProfile.id) } if (!streamClosed) { diff --git a/src/proxy/session/cache.ts b/src/proxy/session/cache.ts index cc49933..5989521 100644 --- a/src/proxy/session/cache.ts +++ b/src/proxy/session/cache.ts @@ -19,6 +19,7 @@ import { // --- Cache setup --- const DEFAULT_MAX_SESSIONS = 1000 +const IMPLICIT_DEFAULT_PROFILE_ID = "default" export function getMaxSessionsLimit(): number { const raw = process.env.CLAUDE_PROXY_MAX_SESSIONS @@ -33,6 +34,11 @@ export function getMaxSessionsLimit(): number { return parsed } +function buildScopedKey(key: string, profileId?: string): string { + if (!profileId || profileId === IMPLICIT_DEFAULT_PROFILE_ID) return key + return `${profileId}:${key}` +} + function removeFingerprintEntriesByClaudeSessionId(claudeSessionId: string): void { for (const [key, state] of fingerprintCache.entries()) { if (state.claudeSessionId === claudeSessionId) { @@ -89,25 +95,28 @@ export function clearSessionCache() { export function evictSession( sessionId: string | undefined, workingDirectory?: string, - messages?: Array<{ role: string; content: any }> + messages?: Array<{ role: string; content: any }>, + profileId?: string ): void { if (sessionId) { - const cached = sessionCache.get(sessionId) + const scopedSessionId = buildScopedKey(sessionId, profileId) + const cached = sessionCache.get(scopedSessionId) if (cached) { removeFingerprintEntriesByClaudeSessionId(cached.claudeSessionId) - sessionCache.delete(sessionId) + sessionCache.delete(scopedSessionId) } - try { evictSharedSession(sessionId) } catch {} + try { evictSharedSession(scopedSessionId) } catch {} } if (messages) { const fp = getConversationFingerprint(messages, workingDirectory) if (fp) { - const cached = fingerprintCache.get(fp) + const scopedFingerprint = buildScopedKey(fp, profileId) + const cached = fingerprintCache.get(scopedFingerprint) if (cached) { removeSessionEntriesByClaudeSessionId(cached.claudeSessionId) - fingerprintCache.delete(fp) + fingerprintCache.delete(scopedFingerprint) } - try { evictSharedSession(fp) } catch {} + try { evictSharedSession(scopedFingerprint) } catch {} } } } @@ -126,28 +135,31 @@ function touchSession(state: SessionState): SessionState { export function lookupSession( sessionId: string | undefined, messages: Array<{ role: string; content: any }>, - workingDirectory?: string + workingDirectory?: string, + profileId?: string ): LineageResult { if (sessionId) { - const cached = sessionCache.get(sessionId) + const scopedSessionId = buildScopedKey(sessionId, profileId) + const cached = sessionCache.get(scopedSessionId) if (cached) { - const result = verifyLineage(cached, messages, sessionId, sessionCache) + const result = verifyLineage(cached, messages, scopedSessionId, sessionCache) if (result.type === "continuation" || result.type === "compaction") touchSession(result.session) return result } - const shared = lookupSharedSession(sessionId) + const shared = lookupSharedSession(scopedSessionId) if (shared) { const state: SessionState = { claudeSessionId: shared.claudeSessionId, + profileId: shared.profileId, lastAccess: Date.now(), messageCount: shared.messageCount || 0, lineageHash: shared.lineageHash || "", messageHashes: shared.messageHashes, sdkMessageUuids: shared.sdkMessageUuids, } - const result = verifyLineage(state, messages, sessionId, sessionCache) + const result = verifyLineage(state, messages, scopedSessionId, sessionCache) if (result.type === "continuation" || result.type === "compaction") { - sessionCache.set(sessionId, state) + sessionCache.set(scopedSessionId, state) } return result } @@ -156,25 +168,27 @@ export function lookupSession( const fp = getConversationFingerprint(messages, workingDirectory) if (fp) { - const cached = fingerprintCache.get(fp) + const scopedFingerprint = buildScopedKey(fp, profileId) + const cached = fingerprintCache.get(scopedFingerprint) if (cached) { - const result = verifyLineage(cached, messages, fp, fingerprintCache) + const result = verifyLineage(cached, messages, scopedFingerprint, fingerprintCache) if (result.type === "continuation" || result.type === "compaction") touchSession(result.session) return result } - const shared = lookupSharedSession(fp) + const shared = lookupSharedSession(scopedFingerprint) if (shared) { const state: SessionState = { claudeSessionId: shared.claudeSessionId, + profileId: shared.profileId, lastAccess: Date.now(), messageCount: shared.messageCount || 0, lineageHash: shared.lineageHash || "", messageHashes: shared.messageHashes, sdkMessageUuids: shared.sdkMessageUuids, } - const result = verifyLineage(state, messages, fp, fingerprintCache) + const result = verifyLineage(state, messages, scopedFingerprint, fingerprintCache) if (result.type === "continuation" || result.type === "compaction") { - fingerprintCache.set(fp, state) + fingerprintCache.set(scopedFingerprint, state) } return result } @@ -190,13 +204,16 @@ export function storeSession( messages: Array<{ role: string; content: any }>, claudeSessionId: string, workingDirectory?: string, - sdkMessageUuids?: Array + sdkMessageUuids?: Array, + profileId?: string, + effectiveProfileId?: string ) { if (!claudeSessionId) return const lineageHash = computeLineageHash(messages) const messageHashes = computeMessageHashes(messages) const state: SessionState = { claudeSessionId, + profileId: effectiveProfileId, lastAccess: Date.now(), messageCount: messages?.length || 0, lineageHash, @@ -204,10 +221,20 @@ export function storeSession( sdkMessageUuids, } // In-memory cache - if (sessionId) sessionCache.set(sessionId, state) + if (sessionId) sessionCache.set(buildScopedKey(sessionId, profileId), state) const fp = getConversationFingerprint(messages, workingDirectory) - if (fp) fingerprintCache.set(fp, state) + if (fp) fingerprintCache.set(buildScopedKey(fp, profileId), state) // Shared file store (cross-proxy resume) const key = sessionId || fp - if (key) storeSharedSession(key, claudeSessionId, state.messageCount, lineageHash, messageHashes, sdkMessageUuids) + if (key) { + storeSharedSession( + buildScopedKey(key, profileId), + claudeSessionId, + state.messageCount, + lineageHash, + messageHashes, + sdkMessageUuids, + effectiveProfileId, + ) + } } diff --git a/src/proxy/session/lineage.ts b/src/proxy/session/lineage.ts index d96d308..5ff52e3 100644 --- a/src/proxy/session/lineage.ts +++ b/src/proxy/session/lineage.ts @@ -17,6 +17,7 @@ export const MIN_SUFFIX_FOR_COMPACTION = 2 export interface SessionState { claudeSessionId: string + profileId?: string lastAccess: number messageCount: number /** Hash of messages[0..messageCount-1] for fast-path lineage verification. diff --git a/src/proxy/sessionStore.ts b/src/proxy/sessionStore.ts index 240d520..17515f1 100644 --- a/src/proxy/sessionStore.ts +++ b/src/proxy/sessionStore.ts @@ -26,6 +26,7 @@ import { join } from "node:path" export interface StoredSession { claudeSessionId: string + profileId?: string createdAt: number lastUsedAt: number messageCount: number @@ -131,7 +132,15 @@ export function lookupSharedSession(key: string): StoredSession | undefined { return store[key] } -export function storeSharedSession(key: string, claudeSessionId: string, messageCount?: number, lineageHash?: string, messageHashes?: string[], sdkMessageUuids?: Array): void { +export function storeSharedSession( + key: string, + claudeSessionId: string, + messageCount?: number, + lineageHash?: string, + messageHashes?: string[], + sdkMessageUuids?: Array, + profileId?: string +): void { const path = getStorePath() const lockPath = `${path}.lock` const hasLock = acquireLock(lockPath) @@ -143,6 +152,7 @@ export function storeSharedSession(key: string, claudeSessionId: string, message const existing = store[key] store[key] = { claudeSessionId, + profileId: profileId ?? existing?.profileId, createdAt: existing?.createdAt || Date.now(), lastUsedAt: Date.now(), messageCount: messageCount ?? existing?.messageCount ?? 0, From 516bf91bb473a2475d89d08ea4ee9f33de338d3d Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:28:08 -0500 Subject: [PATCH 3/4] test: cover profile routing behavior --- src/__tests__/adapter.test.ts | 9 ++ src/__tests__/proxy-profiles.test.ts | 164 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/__tests__/proxy-profiles.test.ts diff --git a/src/__tests__/adapter.test.ts b/src/__tests__/adapter.test.ts index 51f3b42..7675669 100644 --- a/src/__tests__/adapter.test.ts +++ b/src/__tests__/adapter.test.ts @@ -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: "\n Working directory: /Users/test/project\n" diff --git a/src/__tests__/proxy-profiles.test.ts b/src/__tests__/proxy-profiles.test.ts new file mode 100644 index 0000000..dfb7573 --- /dev/null +++ b/src/__tests__/proxy-profiles.test.ts @@ -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 = {}) { + const { app } = createProxyServer({ port: 0, host: "127.0.0.1", ...config }) + return app +} + +async function post(app: any, body: any, headers: Record = {}) { + 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 = {} + + 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") + }) +}) From 92c506e7588b6b4045e14ede21a65c070b7003a6 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:37:51 -0500 Subject: [PATCH 4/4] docs: add local profile routing example --- examples/profile-routing-local.ts | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 examples/profile-routing-local.ts diff --git a/examples/profile-routing-local.ts b/examples/profile-routing-local.ts new file mode 100644 index 0000000..78be674 --- /dev/null +++ b/examples/profile-routing-local.ts @@ -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")