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")
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")
+ })
+})
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/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/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/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,
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,
}