diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ffe99dd293f39..e4f2fd121ab3e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -980,12 +980,17 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { }); describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { - const startTurnWithLaunchArgs = Effect.fnUntraced(function* (launchArgs: string, cwd: string) { + const startTurnWithLaunchArgs = Effect.fnUntraced(function* ( + launchArgs: string, + cwd: string, + proxy?: { url: string; apiKey: string }, + ) { const fileSystem = yield* FileSystem.FileSystem; const opened: Array = []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + proxy, environment: {}, attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-" }), fileSystem, @@ -995,8 +1000,20 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { queryRunner: { allocateSessionId: Effect.succeed("native-thread-claude-append-file"), open: (input) => - Effect.sync(() => { - opened.push(input.options); + Effect.gen(function* () { + const options = + typeof input.options.settings === "string" + ? { + ...input.options, + settings: JSON.parse( + yield* fileSystem.readFileString(input.options.settings).pipe(Effect.orDie), + ) as Exclude< + ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["settings"], + string | undefined + >, + } + : input.options; + opened.push(options); return { messages: Stream.never, offer: () => Effect.void, @@ -1080,7 +1097,7 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { // regression: #15073 the SDK replaced the --settings launch argument with T3's settings it.effect.each([ - `--settings '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}'`, + `--settings '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false,"env":{"ANTHROPIC_BASE_URL":"https://direct.example","KEEP":"yes"}}'`, "--settings user-settings.json", ])("merges %s beneath T3's settings", (launchArgs) => Effect.scoped( @@ -1089,14 +1106,25 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-settings-" }); yield* fileSystem.writeFileString( NodePath.join(cwd, "user-settings.json"), - '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}', + '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false,"env":{"ANTHROPIC_BASE_URL":"https://direct.example","KEEP":"yes"}}', ); - const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd); + const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd, { + url: "http://proxy:8317", + apiKey: "proxy-key", + }); assert.isTrue(Exit.isSuccess(exit)); assert.deepInclude(opened[0]!.settings, { skillOverrides: { lint: "off" }, showThinkingSummaries: true, }); + assert.deepInclude((opened[0]!.settings as { env: Record }).env, { + ANTHROPIC_BASE_URL: "http://proxy:8317", + ANTHROPIC_AUTH_TOKEN: "proxy-key", + ANTHROPIC_API_KEY: "", + CLAUDE_CODE_OAUTH_TOKEN: "", + CLAUDE_CODE_USE_BEDROCK: "0", + KEEP: "yes", + }); assert.isUndefined(opened[0]!.extraArgs?.["settings"]); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a280bd8440a9a..1da9b7e84105e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,3 +1,5 @@ +import * as Scope from "effect/Scope"; +import type { ProviderProxyRoute } from "@t3tools/provider-core/server/providerProxy"; import * as DocumentText from "../../provider/DocumentText.ts"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { claudeTaskOutputRoot, tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; @@ -153,6 +155,9 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { + applyClaudeProxyEnvironment, + applyClaudeProxySettings, + writeClaudeProxySettingsFile, claudeSignedOutMessage, makeClaudeEnvironment, resolveClaudeHomePath, @@ -4474,6 +4479,7 @@ function claudeProposedPlan(input: unknown): string | null { } export interface ClaudeAdapterV2Options { + readonly proxy?: ProviderProxyRoute | undefined; readonly instanceId: ProviderInstanceId; readonly settings: ClaudeSettings; readonly environment: NodeJS.ProcessEnv; @@ -10219,9 +10225,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - ...(launchArgSettings === undefined + ...(launchArgSettings === undefined && adapterOptions.proxy === undefined ? {} - : { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }), + : { + sdkSettings: applyClaudeProxySettings( + launchArgSettings === undefined + ? {} + : claudeSdkLaunchArgSettings(launchArgSettings), + adapterOptions.proxy, + ), + }), environment: claudeQueryEnvironment( adapterOptions.environment, turnInput.appThread, @@ -10253,11 +10266,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const resolvedTurnCwd = yield* fileSystem .realPath(turnInput.runtimePolicy.cwd ?? ".") .pipe(Effect.orElseSucceed(() => turnInput.runtimePolicy.cwd)); + const routedQueryOptions = + adapterOptions.proxy === undefined + ? queryOptions + : { + ...queryOptions, + settings: yield* writeClaudeProxySettingsFile( + queryOptions.settings as ClaudeSdkSettings, + adapterOptions.environment, + turnInput.runtimePolicy.cwd, + ).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + }; const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId, - options: queryOptions, + options: routedQueryOptions, }) .pipe( Effect.onError(() => @@ -11506,14 +11534,17 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( input: ProviderAdapterDriverCreateInput, hooks: Pick = {}, ) { - const { instanceId, environment, enabled, config } = input; + const { instanceId, environment, enabled, config, proxy } = input; const fileSystem = yield* FileSystem.FileSystem; const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig.ServerConfig; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const baseEnvironment = yield* mergeProviderInstanceEnvironment(environment, hostEnvironment); + const baseEnvironment = applyClaudeProxyEnvironment( + yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + proxy, + ); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; @@ -11525,6 +11556,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( instanceId, settings: { ...config, enabled, binaryPath }, environment: claudeEnvironment, + proxy, attachmentsDir: serverConfig.attachmentsDir, fileSystem, path, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 90ee44f3d5b6f..7a47806744bac 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -68,6 +68,7 @@ import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as CodexLaunchArgs from "../../provider/codexLaunchArgs.ts"; import * as ModelManifest from "../../provider/ModelManifest.ts"; import { resolveProviderCompatibility } from "../../provider/providerCompatibility.ts"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; @@ -1142,6 +1143,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + modelProvider?: string, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1191,6 +1193,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, + modelProvider, clientFactory, crypto: yield* Crypto.Crypto, fileSystem, @@ -2418,6 +2421,80 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("pins thread start and resume to the proxy provider when no model is resent", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "proxied-thread"; + const modelProvider = CodexLaunchArgs.CODEX_PROXY_MODEL_PROVIDER; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }) + .slice(0, 5) + .map((entry) => + entry.type === "expect_outbound" && entry.label === "thread/start" + ? { + ...entry, + frame: { + id: 2, + method: "thread/start", + params: { modelProvider, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, + }, + } + : entry, + ); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "proxy-resume", + entries: [ + ...preamble, + { + type: "expect_outbound", + label: "resume through the proxy", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + modelProvider, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + label: "resume result", + frame: { + id: 3, + result: { + thread: { id: nativeThreadId, updatedAt: 1782622450, turns: [] }, + }, + }, + }, + ], + }), + undefined, + undefined, + undefined, + undefined, + modelProvider, + ); + + const resumed = yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + }); + + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + // regression: #15389 archived native Codex sessions resume via thread/unarchive it.effect.each( ( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 821cf051c3235..3b94b8394642b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -118,7 +118,12 @@ import { shouldPersistProviderEvent, } from "../../provider/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; +import { + applyCodexProxyRuntime, + CODEX_PROXY_MODEL_PROVIDER, + codexAppServerArgs, + resolveCodexLaunchArgs, +} from "../../provider/codexLaunchArgs.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -1337,15 +1342,19 @@ function codexThreadRuntimeParams(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; readonly modelSelection?: { readonly model: string }; readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly modelProvider?: string | undefined; }): { readonly cwd?: string; readonly model?: string; + readonly modelProvider?: string; readonly config: Readonly>; } { const { mcpSession } = input; return { ...(input.runtimePolicy?.cwd == null ? {} : { cwd: input.runtimePolicy.cwd }), ...(input.modelSelection === undefined ? {} : { model: input.modelSelection.model }), + // Without it, a resume reuses the provider stored in the rollout and bypasses the proxy. + ...(input.modelProvider === undefined ? {} : { modelProvider: input.modelProvider }), config: { ...CODEX_THREAD_CONFIG, ...(mcpSession === undefined @@ -1562,7 +1571,13 @@ export type CodexAdapterV2DriverEnv = | ServerConfig; export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( - { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, + { + instanceId, + environment, + enabled, + config, + proxy, + }: ProviderAdapterDriverCreateInput, hooks: Pick = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; @@ -1586,8 +1601,13 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter ), ); + const routed = applyCodexProxyRuntime( + config, + yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + proxy, + ); const settings = { - ...config, + ...routed.config, enabled, binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", @@ -1596,7 +1616,8 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter return yield* makeCodexAdapterV2({ instanceId, settings, - environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: routed.environment, + ...(proxy === undefined ? {} : { modelProvider: CODEX_PROXY_MODEL_PROVIDER }), clientFactory, crypto, fileSystem, @@ -1613,6 +1634,8 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + /** Pins thread start and resume to this Codex model provider. */ + readonly modelProvider?: string | undefined; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -3045,6 +3068,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( .request("thread/resume", { threadId: input.nativeThreadId, excludeTurns: true, + ...(adapterOptions.modelProvider === undefined + ? {} + : { modelProvider: adapterOptions.modelProvider }), }) .pipe(Effect.flatMap(decodeCodexChildModel)), ), @@ -6957,6 +6983,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( client.request( "thread/start", codexThreadRuntimeParams({ + modelProvider: adapterOptions.modelProvider, mcpSession, modelSelection: threadInput.modelSelection, runtimePolicy: threadInput.runtimePolicy, @@ -6989,6 +7016,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( threadId: nativeThreadId, excludeTurns: true, ...codexThreadRuntimeParams({ + modelProvider: adapterOptions.modelProvider, mcpSession: yield* readMcpSession( threadInput.threadId ?? threadInput.providerThread.appThreadId, ), @@ -7776,6 +7804,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( threadId, excludeTurns: true, ...codexThreadRuntimeParams({ + modelProvider: adapterOptions.modelProvider, mcpSession: yield* readMcpSession(threadInput.providerThread.appThreadId), modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, @@ -7828,6 +7857,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? {} : { lastTurnId: boundary.lastTurnId }), ...codexThreadRuntimeParams({ + modelProvider: adapterOptions.modelProvider, mcpSession: yield* mcpSessions.read(threadInput.targetThreadId), ...(threadInput.modelSelection === undefined ? {} diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 95f5c6709a4ea..54cd0db6a59cb 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -36,10 +36,20 @@ import { type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; -import { hasClaudeManagedMcpConfig, makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; +import { + applyClaudeProxySettings, + writeClaudeProxySettingsFile, + hasClaudeManagedMcpConfig, + makeClaudeEnvironment, +} from "./Drivers/ClaudeHome.ts"; import { discoverClaudeSkills, readClaudeSettingsBaseUrl } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + type ProviderProxyRoute, + PROXY_AUTH_METADATA, + PROXY_LIMITS_MESSAGE, +} from "@t3tools/provider-core/server/providerProxy"; import { type ClaudeScopedLimitNames, claudeUsageResponseToLimits, @@ -201,6 +211,7 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { readonly environment: NodeJS.ProcessEnv; readonly cwd: string | undefined; readonly managedMcpConfig: boolean; + readonly proxy?: ProviderProxyRoute | undefined; }): ClaudeQueryOptions { return { persistSession: false, @@ -208,7 +219,7 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { abortController: input.abortController, settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], // Keep settings for slash-command discovery, but disable hooks so health checks do not run SessionStart repeatedly. - settings: { disableAllHooks: true }, + settings: applyClaudeProxySettings({ disableAllHooks: true }, input.proxy), allowedTools: [], // Exclude filesystem MCP servers; omit strict mode when managed-mcp.json already excludes them and forbids the flag. mcpServers: {}, @@ -324,6 +335,7 @@ const probeClaudeCapabilities = ( environment?: NodeJS.ProcessEnv, cwd?: string, includeUsage = true, + proxy?: ProviderProxyRoute, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -333,19 +345,29 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, + abortController: abort, + environment: claudeEnvironment, + cwd, + managedMcpConfig, + proxy, + }); + const settings = + proxy === undefined + ? options.settings + : yield* writeClaudeProxySettingsFile( + options.settings as import("@anthropic-ai/claude-agent-sdk").Settings, + claudeEnvironment, + cwd, + ); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), - options: buildClaudeCapabilitiesProbeQueryOptions({ - executablePath, - abortController: abort, - environment: claudeEnvironment, - cwd, - managedMcpConfig, - }), + options: { ...options, ...(settings === undefined ? {} : { settings }) }, }); const init = await q.initializationResult(); return { q, init }; @@ -398,6 +420,7 @@ const probeClaudeCapabilities = ( Effect.tapError((error) => Effect.logWarning("Claude capability probe failed.", { errorTag: error._tag }), ), + Effect.scoped, ); }; @@ -426,6 +449,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps machineSnapshot: ServerProvider, cwd: string, environment?: NodeJS.ProcessEnv, + proxy?: ProviderProxyRoute, ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); @@ -433,7 +457,9 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); const probeResult = cwdExists - ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false, proxy).pipe( + Effect.result, + ) : undefined; const capabilities = probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; @@ -459,6 +485,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ scopedLimitNames?: Ref.Ref, resolveResetCredits?: (version: string) => Effect.Effect, + routedThroughProxy = false, ): Effect.fn.Return< ServerProviderDraft, never, @@ -628,26 +655,32 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - // A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for. - const authMetadata = - apiProviderAuthMetadata(capabilities.apiProvider) ?? - (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ?? - claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }); + const authMetadata = routedThroughProxy + ? PROXY_AUTH_METADATA + : (apiProviderAuthMetadata(capabilities.apiProvider) ?? + (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ?? + claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + })); const usageCheckedAt = capabilities.usageCheckedAt ?? checkedAt; - const usageLimits = !capabilities.usage - ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) - : scopedLimitNames - ? yield* recordClaudeUsageResponse(scopedLimitNames, { - response: capabilities.usage, - checkedAt: usageCheckedAt, - }) - : claudeUsageResponseToLimits({ - response: capabilities.usage, - checkedAt: usageCheckedAt, - }).limits; + const usageLimits = routedThroughProxy + ? makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: PROXY_LIMITS_MESSAGE, + }) + : !capabilities.usage + ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) + : scopedLimitNames + ? yield* recordClaudeUsageResponse(scopedLimitNames, { + response: capabilities.usage, + checkedAt: usageCheckedAt, + }) + : claudeUsageResponseToLimits({ + response: capabilities.usage, + checkedAt: usageCheckedAt, + }).limits; const resetCredits = resolveResetCredits && capabilities.subscriptionType && diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ed9e3d0cde684..e02f3fad8fc91 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -46,6 +46,10 @@ import { } from "@t3tools/provider-core/server/snapshotProbe"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + PROXY_AUTH_METADATA, + PROXY_LIMITS_MESSAGE, +} from "@t3tools/provider-core/server/providerProxy"; import { codexRateLimitsFailureMessage, codexRateLimitsToLimits, @@ -749,6 +753,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, managedAuth?: ServerProvider["auth"], + routedThroughProxy = false, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -781,7 +786,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), environment: resolvedEnvironment, - ...(managedAuth ? { skipNativeUsage: true } : {}), + ...(managedAuth || routedThroughProxy ? { skipNativeUsage: true } : {}), }).pipe( // Process teardown can outlast the work budget without discarding a completed snapshot. Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)), @@ -841,9 +846,20 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot); - const usageLimits = - snapshot.account.account?.type === "apiKey" + : routedThroughProxy + ? { + status: "ready" as const, + auth: { status: "authenticated" as const, ...PROXY_AUTH_METADATA }, + message: undefined, + } + : accountProbeStatus(snapshot); + const usageLimits = routedThroughProxy + ? makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: PROXY_LIMITS_MESSAGE, + }) + : snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e1fbcd30080fd..c0a536f83a49b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -56,6 +56,7 @@ import { type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; import { + applyClaudeProxyEnvironment, makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, resolveClaudeHomePath, @@ -120,6 +121,7 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update) }, ).pipe( @@ -195,6 +201,7 @@ export const ClaudeDriver: ProviderDriver(undefined); const capabilitiesProbeCache = yield* Cache.makeWith( () => - probeClaudeCapabilities(effectiveConfig, processEnv, stateDir).pipe( + probeClaudeCapabilities( + effectiveConfig, + processEnv, + stateDir, + proxy === undefined, + proxy, + ).pipe( Effect.flatMap((capabilities) => ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( Effect.map((workspaceId) => ({ @@ -238,10 +251,10 @@ export const ClaudeDriver: ProviderDriver - // Only fresh usage earns the TTL; failures, fallbacks and usage-less probes retry. + // Proxy probes skip native usage; successful initialization still earns the TTL. Exit.isSuccess(exit) && exit.value._tag === "fresh" && - exit.value.capabilities.usage !== undefined + (proxy !== undefined || exit.value.capabilities.usage !== undefined) ? CAPABILITIES_PROBE_TTL : Duration.zero, }, @@ -272,6 +285,7 @@ export const ClaudeDriver: ProviderDriver @@ -400,7 +414,7 @@ export const ClaudeDriver: ProviderDriver snapshot.getSnapshot.pipe( Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), + probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv, proxy), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index b9eb3b806ce0a..5414da2df148c 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -4,19 +4,91 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; import { + writeClaudeProxySettingsFile, + applyClaudeProxyEnvironment, + applyClaudeProxySettings, makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, resolveClaudeHomePath, } from "./ClaudeHome.ts"; +describe("applyClaudeProxyEnvironment", () => { + it("replaces every ambient route with the proxy", () => { + expect( + applyClaudeProxyEnvironment( + { + PATH: "/bin", + ANTHROPIC_API_KEY: "sk-ant-direct", + ANTHROPIC_BASE_URL: "https://api.anthropic.com", + CLAUDE_CODE_OAUTH_TOKEN: "oauth", + CLAUDE_CODE_USE_BEDROCK: "1", + }, + { url: "http://proxy:8317", apiKey: "sk-proxy" }, + ), + ).toEqual({ + PATH: "/bin", + ANTHROPIC_API_KEY: "", + ANTHROPIC_BASE_URL: "http://proxy:8317", + ANTHROPIC_AUTH_TOKEN: "sk-proxy", + }); + const settings = { + env: { KEEP: "yes", ANTHROPIC_BASE_URL: "https://direct.example" }, + disableAllHooks: true, + }; + expect(applyClaudeProxySettings(settings, undefined)).toBe(settings); + expect( + applyClaudeProxySettings(settings, { url: "http://proxy:8317", apiKey: "" }), + ).toMatchObject({ + disableAllHooks: true, + env: { + KEEP: "yes", + ANTHROPIC_BASE_URL: "http://proxy:8317", + ANTHROPIC_AUTH_TOKEN: "t3-proxy", + CLAUDE_CODE_USE_GATEWAY: "0", + }, + }); + // Claude Code reports "Not logged in" without contacting a proxy when no credential is set. + expect( + applyClaudeProxyEnvironment({}, { url: "http://proxy:8317", apiKey: "" }) + .ANTHROPIC_AUTH_TOKEN, + ).toBe("t3-proxy"); + }); +}); + it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => Effect.gen(function* () { const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const proxySettings = applyClaudeProxySettings( + {}, + { url: "http://proxy:8317", apiKey: "test" }, + ); + yield* fileSystem.writeFileString( + path.join(directory, "remote-settings.json"), + '{"env":{"ANTHROPIC_BASE_URL":"https://managed.example"}}', + ); + const conflict = yield* Effect.flip( + writeClaudeProxySettingsFile(proxySettings, { CLAUDE_CONFIG_DIR: directory }), + ); + expect(conflict).toMatchObject({ + _tag: "ClaudeProxySettingsError", + message: expect.stringContaining("managed policy conflicts"), + }); + yield* fileSystem.writeFileString( + path.join(directory, "remote-settings.json"), + '{"disableAllHooks":true,"env":{"ANTHROPIC_MODEL":"claude-haiku-5-5"}}', + ); + const settingsPath = yield* writeClaudeProxySettingsFile(proxySettings, { + CLAUDE_CONFIG_DIR: directory, + }); + expect(yield* fileSystem.readFileString(settingsPath)).toContain("http://proxy:8317"); const homePath = "~/.claude-work"; const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); @@ -26,7 +98,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( `claude\0${resolved}\0`, ); - }), + }).pipe(Effect.scoped), ); it.effect("uses inherited CLAUDE_CONFIG_DIR when homePath is empty", () => diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bd81aa10bce7a..739c6b0739839 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,12 +1,26 @@ +import { + resolveSettings, + type Settings as ClaudeSdkSettings, +} from "@anthropic-ai/claude-agent-sdk"; import type { ClaudeSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Data from "effect/Data"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { + providerProxyToken, + type ProviderProxyRoute, +} from "@t3tools/provider-core/server/providerProxy"; import * as HostProcess from "@t3tools/shared/HostProcess"; +class ClaudeProxySettingsError extends Data.TaggedError("ClaudeProxySettingsError")<{ + readonly message: string; +}> {} + +const encodeSettings = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( @@ -42,6 +56,101 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }; }); +// Ambient Bedrock, Vertex, or API-key settings would send requests around the proxy. +const CLAUDE_ROUTE_OVERRIDE_VARIABLES = [ + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", + "CLAUDE_CODE_USE_MANTLE", + "CLAUDE_CODE_USE_GATEWAY", +] as const; + +export const applyClaudeProxyEnvironment = ( + environment: NodeJS.ProcessEnv, + proxy: ProviderProxyRoute | undefined, +): NodeJS.ProcessEnv => { + if (proxy === undefined) return environment; + const next: NodeJS.ProcessEnv = { + ...environment, + ANTHROPIC_BASE_URL: proxy.url, + ANTHROPIC_AUTH_TOKEN: providerProxyToken(proxy), + // Empty, not removed: Claude Code otherwise falls back to a saved API key. + ANTHROPIC_API_KEY: "", + }; + for (const name of CLAUDE_ROUTE_OVERRIDE_VARIABLES) delete next[name]; + return next; +}; + +// Claude applies settings env over process env; flag settings pin the route for saved configs too. +export const applyClaudeProxySettings = ( + settings: ClaudeSdkSettings, + proxy: ProviderProxyRoute | undefined, +): ClaudeSdkSettings => { + if (proxy === undefined) return settings; + return { + ...settings, + env: { + ...settings?.env, + ...Object.fromEntries(CLAUDE_ROUTE_OVERRIDE_VARIABLES.map((name) => [name, "0"])), + ANTHROPIC_BASE_URL: proxy.url, + ANTHROPIC_AUTH_TOKEN: providerProxyToken(proxy), + ANTHROPIC_API_KEY: "", + CLAUDE_CODE_OAUTH_TOKEN: "", + }, + }; +}; + +// A private settings file keeps proxy credentials out of process arguments. +export const writeClaudeProxySettingsFile = Effect.fn("writeClaudeProxySettingsFile")(function* ( + settings: ClaudeSdkSettings, + environment: NodeJS.ProcessEnv, + cwd?: string | null, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const remotePath = path.join( + environment.CLAUDE_CONFIG_DIR ?? path.join(yield* HostProcess.HomeDirectory, ".claude"), + "remote-settings.json", + ); + const remote = yield* fileSystem.readFileString(remotePath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); + const resolved = yield* Effect.tryPromise(() => + resolveSettings({ + ...(cwd == null ? {} : { cwd }), + settingSources: [], + ...(remote === undefined ? {} : { serverManagedSettings: remote as ClaudeSdkSettings }), + }), + ); + const managed = resolved.effective; + const expected = settings.env ?? {}; + if ( + managed.policyHelper !== undefined || + managed.policyHelpers !== undefined || + Object.entries(managed.env ?? {}).some( + ([key, value]) => + (["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"].includes(key) || + CLAUDE_ROUTE_OVERRIDE_VARIABLES.includes( + key as (typeof CLAUDE_ROUTE_OVERRIDE_VARIABLES)[number], + )) && + value !== expected[key], + ) + ) { + return yield* new ClaudeProxySettingsError({ + message: + "Claude managed policy conflicts with proxy routing. Remove its routing overrides or disable Use proxy.", + }); + } + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-proxy-" }); + const settingsPath = path.join(directory, "settings.json"); + yield* fileSystem.writeFileString(settingsPath, encodeSettings(settings), { mode: 0o600 }); + return settingsPath; +}); + /** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 97ec4954ed840..f8bf06c535a8e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -26,7 +26,7 @@ import { probeCodexSkillsForCwd, withCodexAppServerClient, } from "../CodexProvider.ts"; -import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; +import { applyCodexProxyRuntime, resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { applyCodexModelCatalog } from "../codexModelCatalog.ts"; @@ -131,7 +131,15 @@ export const CodexDriver: ProviderDriver< environment, enabled, config, + proxy, }) { + if (config.setupMode === "managed" && proxy !== undefined) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "T3-managed ChatGPT sign-in cannot route through a proxy. Turn off Use proxy.", + }); + } if (config.setupMode === "managed") return yield* makeManagedCodexProvider({ instanceId, @@ -150,7 +158,12 @@ export const CodexDriver: ProviderDriver< const installation = yield* CodexInstallation.CodexInstallation; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); - const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const routed = applyCodexProxyRuntime( + config, + yield* mergeProviderInstanceEnvironment(environment), + proxy, + ); + const processEnv = routed.environment; const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ @@ -172,7 +185,7 @@ export const CodexDriver: ProviderDriver< ), ); const effectiveConfig = { - ...config, + ...routed.config, enabled, binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", @@ -199,6 +212,7 @@ export const CodexDriver: ProviderDriver< environment, enabled, config, + proxy, }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), @@ -221,7 +235,13 @@ export const CodexDriver: ProviderDriver< const checkProvider = modelCatalog.refreshInBackground.pipe( Effect.andThen( Effect.zipWith( - checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + checkCodexProviderStatus( + effectiveConfig, + undefined, + processEnv, + undefined, + proxy !== undefined, + ), currentCatalog, (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), { concurrent: true }, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index af3359ea7f27d..36d58062a255e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -786,6 +786,39 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { expect(ghost.unavailableReason).toMatch(/ghostDriver/); }).pipe(Effect.provide(layerTest)), ); + + it.live( + "rebuilds a proxied instance when the proxy changes and refuses one without a proxy", + () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_proxied"); + const entry = { + driver: ProviderDriverKind.make("codex"), + useProxy: true, + config: makeCodexConfig({ enabled: false }), + }; + const { registry, mutator } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { [codexId]: { ...entry, proxy: { url: "http://a:8317", apiKey: "k" } } }, + }); + const first = yield* registry.getInstance(codexId); + expect(first).toBeDefined(); + + yield* mutator.reconcile({ + [codexId]: { ...entry, proxy: { url: "http://b:8317", apiKey: "k" } }, + }); + const second = yield* registry.getInstance(codexId); + expect(second).toBeDefined(); + expect(second).not.toBe(first); + + // Running on the instance's own login instead would spend a different account. + yield* mutator.reconcile({ [codexId]: entry }); + expect(yield* registry.getInstance(codexId)).toBeUndefined(); + const [unavailable] = yield* registry.listUnavailable; + expect(unavailable?.instanceId).toBe(codexId); + expect(unavailable?.unavailableReason).toMatch(/no proxy/); + }).pipe(Effect.provide(layerTest)), + ); }); const DisabledByDefaultConfig = Schema.Struct({ diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index a944f751a1d34..93f4abcd1a559 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -1,8 +1,8 @@ import { + PROXY_ROUTED_DRIVERS, providerInstanceConfigEnabledFlag, ProviderInstanceId, type ProviderInstanceConfig, - type ProviderInstanceConfigMap, type ProviderInstanceEnvironment, type ProviderDriverKind, type ResolvedProviderInstanceEnvironment, @@ -51,7 +51,7 @@ interface InstanceSecrets { interface LiveEntry { readonly instance: ProviderInstance; readonly scope: Scope.Closeable; - readonly entry: ProviderInstanceConfig; + readonly entry: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeEntry; readonly secrets: InstanceSecrets; } @@ -61,8 +61,24 @@ interface RegistryState { readonly changes: PubSub.PubSub; } -const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => - Equal.equals(a, b); +const entryEqual = ( + a: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeEntry, + b: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeEntry, +): boolean => Equal.equals(a, b); + +const proxyUnavailableReason = ( + entry: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeEntry, +): string | undefined => { + if (entry.useProxy !== true) return undefined; + if (!PROXY_ROUTED_DRIVERS.has(entry.driver)) { + return `Driver '${entry.driver}' cannot route through a proxy. Turn off Use proxy for this instance.`; + } + // Falling back to the instance's own login would silently spend a different account. + if (entry.proxy === undefined) { + return "Use proxy is on, but this environment has no proxy. Add one in Settings → Providers → Proxy, or turn off Use proxy."; + } + return undefined; +}; const secretReferencesIn = (environment: ProviderInstanceEnvironment): Array => environment.flatMap((variable) => (typeof variable.value === "string" ? [] : [variable.value])); @@ -146,7 +162,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; }): (( - configMap: ProviderInstanceConfigMap, + configMap: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap, rebuild?: ReadonlySet, deferSecrets?: boolean, ) => Effect.Effect) => { @@ -401,7 +432,7 @@ export const makeProviderInstanceRegistry = Effect.fn( "ProviderInstanceRegistry.makeProviderInstanceRegistry", )(function* (input: { readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; + readonly configMap: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap; }): Effect.fn.Return< { readonly registry: ProviderInstanceRegistry["Service"]; @@ -534,7 +565,7 @@ export const makeProviderInstanceRegistry = Effect.fn( export const layer = (input: { readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; + readonly configMap: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap; }): Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index c497e00ac17e9..fb16fa967d0e7 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -77,6 +77,28 @@ describe("deriveProviderInstanceConfigMap", () => { expect(merged[instanceId]).toBeUndefined(); }); + it("attaches the environment's proxy only to instances that use it", () => { + const claudeId = ProviderInstanceId.make("claude_direct"); + const settings = settingsWith({ + providerProxy: { url: " http://proxy:8317/v1/ ", apiKey: "sk-proxy", managementKey: "" }, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), useProxy: true }, + [claudeId]: { driver: ProviderDriverKind.make("claudeAgent") }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]?.proxy).toEqual({ url: "http://proxy:8317", apiKey: "sk-proxy" }); + expect(merged[claudeId]).toBe(settings.providerInstances[claudeId]); + expect( + deriveProviderInstanceConfigMap({ + ...settings, + providerProxy: { ...settings.providerProxy, url: "" }, + })[instanceId]?.proxy, + ).toBeUndefined(); + }); + it("ignores a project override that disables an instance, leaving the machine's config in charge", () => { const settings = settingsWith({ providerInstances: { diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index c8f2fb9b33e05..aee2babfa5642 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -5,9 +5,12 @@ import { providerInstanceConfigEnabledFlag, resolveProviderInstanceEnabled, type ProviderInstanceConfig, - type ProviderInstanceConfigMap, ServerSettings, } from "@t3tools/contracts"; +import { + normalizeProviderProxyUrl, + type ProviderProxyRoute, +} from "@t3tools/provider-core/server/providerProxy"; import * as HostProcess from "@t3tools/shared/HostProcess"; import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -46,9 +49,14 @@ type ProviderInstanceRegistryHydrationEnv = // The Claude runner counts its CLIs as running agents for port discovery. | PortScanner.PortDiscovery; +const resolveProviderProxyRoute = (settings: ServerSettings): ProviderProxyRoute | undefined => { + const url = normalizeProviderProxyUrl(settings.providerProxy.url); + return url === "" ? undefined : { url, apiKey: settings.providerProxy.apiKey }; +}; + export const deriveProviderInstanceConfigMap = ( settings: ServerSettings, -): ProviderInstanceConfigMap => { +): ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap => { const merged: Record = { ...settings.providerInstances }; for (const driver of BUILT_IN_DRIVERS) { @@ -77,7 +85,14 @@ export const deriveProviderInstanceConfigMap = ( } } - return merged as ProviderInstanceConfigMap; + const proxy = resolveProviderProxyRoute(settings); + if (proxy === undefined) return merged; + return Object.fromEntries( + Object.entries(merged).map(([id, entry]) => [ + id, + entry.useProxy === true ? { ...entry, proxy } : entry, + ]), + ); }; export const DEV_REPLAY_INSTANCE_ID = ProviderInstanceId.make("devReplay"); @@ -162,7 +177,7 @@ export const layer: Layer.Layer< ); const initialConfigMap = initialSettings === undefined - ? ({} as ProviderInstanceConfigMap) + ? ({} as ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap) : deriveProviderInstanceConfigMap(initialSettings); const layerMutable = ProviderInstanceRegistry.layer({ diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index 35eb52d3f2906..d9c3ab123aca4 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,10 +1,17 @@ /** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ -import type { ProviderInstanceConfigMap, ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderInstanceConfig, ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderProxyRoute } from "@t3tools/provider-core/server/providerProxy"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +/** Runtime-only inputs ride on the entry, so a change to them rebuilds the instance. */ +export type ProviderInstanceRuntimeEntry = ProviderInstanceConfig & { + readonly proxy?: ProviderProxyRoute; +}; +export type ProviderInstanceRuntimeMap = Readonly>; + export interface ProviderInstanceRegistryMutatorShape { - readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; + readonly reconcile: (configMap: ProviderInstanceRuntimeMap) => Effect.Effect; /** Re-reads the secrets of every instance, or one, and rebuilds those whose secrets changed. */ readonly reloadSecrets: (instanceId?: ProviderInstanceId) => Effect.Effect; } diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts index ce18c8f3f1bd5..cb62bceedfa72 100644 --- a/apps/server/src/provider/codexLaunchArgs.test.ts +++ b/apps/server/src/provider/codexLaunchArgs.test.ts @@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; -import { codexAppServerArgs, codexExecLaunchArgs } from "./codexLaunchArgs.ts"; +import { + applyCodexProxyRuntime, + codexAppServerArgs, + codexExecLaunchArgs, +} from "./codexLaunchArgs.ts"; describe("codexAppServerArgs", () => { it("preserves escaped TOML quotes in config values", () => { @@ -27,3 +31,30 @@ describe("codexExecLaunchArgs", () => { ]); }); }); + +describe("applyCodexProxyRuntime", () => { + const proxyProvider = + 'model_providers.t3_proxy={name="T3 proxy",base_url="http://proxy:8317/v1",env_key="T3_PROXY_KEY",wire_api="responses",requires_openai_auth=false,supports_websockets=false}'; + + it("adds the proxy provider after env launch args and keeps the key out of argv", () => { + const { config, environment } = applyCodexProxyRuntime( + { launchArgs: "--ignored" }, + { T3CODE_CODEX_LAUNCH_ARGS: "--strict-config", PATH: "/bin" }, + { url: "http://proxy:8317", apiKey: "sk-proxy" }, + ); + + NodeAssert.deepStrictEqual(codexAppServerArgs(config.launchArgs), [ + "app-server", + "--strict-config", + "-c", + 'model_provider="t3_proxy"', + "-c", + proxyProvider, + ]); + NodeAssert.deepStrictEqual(codexExecLaunchArgs(config.launchArgs).slice(-2), [ + "-c", + proxyProvider, + ]); + NodeAssert.deepStrictEqual(environment, { PATH: "/bin", T3_PROXY_KEY: "sk-proxy" }); + }); +}); diff --git a/apps/server/src/provider/codexLaunchArgs.ts b/apps/server/src/provider/codexLaunchArgs.ts index 33def5ce9a61a..7a182197bef18 100644 --- a/apps/server/src/provider/codexLaunchArgs.ts +++ b/apps/server/src/provider/codexLaunchArgs.ts @@ -1,3 +1,8 @@ +import { + providerProxyApiUrl, + providerProxyToken, + type ProviderProxyRoute, +} from "@t3tools/provider-core/server/providerProxy"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; @@ -7,6 +12,36 @@ export const resolveCodexLaunchArgs = ( environment: NodeJS.ProcessEnv = process.env, ) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; +// Codex stores this ID in each rollout and resumes through it; renaming it strands proxied threads. +export const CODEX_PROXY_MODEL_PROVIDER = "t3_proxy"; +const CODEX_PROXY_KEY_ENV = "T3_PROXY_KEY"; + +/** Appends the proxy provider after any configured launch arguments; the key stays out of argv. */ +export const applyCodexProxyRuntime = ( + config: Config, + environment: NodeJS.ProcessEnv, + proxy: ProviderProxyRoute | undefined, +): { readonly config: Config; readonly environment: NodeJS.ProcessEnv } => { + if (proxy === undefined) return { config, environment }; + const proxyArgs = [ + `model_provider="${CODEX_PROXY_MODEL_PROVIDER}"`, + `model_providers.${CODEX_PROXY_MODEL_PROVIDER}={name="T3 proxy",base_url=${JSON.stringify(providerProxyApiUrl(proxy))},env_key="${CODEX_PROXY_KEY_ENV}",wire_api="responses",requires_openai_auth=false,supports_websockets=false}`, + ] + .map((value) => `-c ${JSON.stringify(value)}`) + .join(" "); + const launchArgs = resolveCodexLaunchArgs(config.launchArgs, environment); + const next: NodeJS.ProcessEnv = { + ...environment, + [CODEX_PROXY_KEY_ENV]: providerProxyToken(proxy), + }; + // Folded into launchArgs above; left in place it would replace the proxy arguments. + delete next[T3CODE_CODEX_LAUNCH_ARGS_ENV]; + return { + config: { ...config, launchArgs: launchArgs === "" ? proxyArgs : `${launchArgs} ${proxyArgs}` }, + environment: next, + }; +}; + const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs); export const codexAppServerArgs = (launchArgs?: string) => [ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ef8b4d6afae79..add0ee73d026c 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1268,7 +1268,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); it.effect( - "uses the same secret lifecycle for provider config, environment, host tokens, and quota keys", + "uses the same secret lifecycle for provider config, environment, host tokens, quota and proxy keys", () => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -1300,6 +1300,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, }, }, + providerProxy: { + url: "http://proxy.example.test:8317", + apiKey: "proxy-client-secret", + managementKey: "proxy-management-secret", + }, easDeviceHosts: [ { kind: "eas", @@ -1319,6 +1324,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { "bitbucket-secret", "gitcafe-secret", "quota-secret", + "proxy-client-secret", + "proxy-management-secret", "expo-secret", ]; const client = ServerSettingsModule.redactServerSettingsForClient(saved); @@ -1334,6 +1341,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { providerInstances: client.providerInstances, sourceControlHosts: client.sourceControlHosts, usageLimitSources: client.usageLimitSources, + providerProxy: client.providerProxy, easDeviceHosts: client.easDeviceHosts, }); for (const value of values) assert.include(JSON.stringify(echoed), value); @@ -1384,6 +1392,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), sourceControlHostSecretName({ kind: "gitcafe", field: "token", serverHost: null }), "usage-limit-source-aHVi", + "provider-proxy-YXBpS2V5", + "provider-proxy-bWFuYWdlbWVudEtleQ", ]; for (const name of secretNames) { assert.isTrue(Option.isSome(yield* store.get(name)), name); @@ -1417,6 +1427,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { gitcafe: { token: "" }, }, usageLimitSources: { [UsageLimitSourceId.make("hub")]: null }, + providerProxy: { apiKey: "", managementKey: "" }, }); for (const name of secretNames) { assert.isTrue(Option.isNone(yield* store.get(name))); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d4daa659e58a..947d3368f516c 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -3,6 +3,7 @@ import { ClaudeSettings, CodexSettings, EasDeviceHostConfig, + ProviderProxySettings, type ServerSettings, SimlockDeviceHostConfig, UsageLimitSourceConfig, @@ -34,6 +35,7 @@ const providerSecretFields = new Map([ ), ]); const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); +const providerProxySecretFields = secretSettingsFields(ProviderProxySettings); const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); const simlockDeviceHostSecretFields = secretSettingsFields(SimlockDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); @@ -150,6 +152,13 @@ export function mapSettingsSecrets( ]), ) as ServerSettings["usageLimitSources"]; + const providerProxy = mapFields( + settings.providerProxy, + providerProxySecretFields, + (field, serverHost) => + `provider-proxy-${encode(field)}` + (serverHost === null ? "" : `-${encode(serverHost)}`), + ) as ServerSettings["providerProxy"]; + const easDeviceHosts = settings.easDeviceHosts.map( (host) => mapFields( @@ -178,6 +187,7 @@ export function mapSettingsSecrets( providerInstances, sourceControlHosts, usageLimitSources, + providerProxy, easDeviceHosts, simlockDeviceHosts, }; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8fa9ccb1c8094..e181a147d1590 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -8,6 +8,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import type { ProviderProxyRoute } from "@t3tools/provider-core/server/providerProxy"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; import { normalizeCliError, @@ -30,6 +31,8 @@ import { scopeClaudeModelCatalog, } from "../provider/ClaudeModelCatalog.ts"; import { + applyClaudeProxySettings, + writeClaudeProxySettingsFile, hasClaudeManagedMcpConfig, makeClaudeEnvironment, } from "../provider/Drivers/ClaudeHome.ts"; @@ -81,6 +84,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + proxy?: ProviderProxyRoute, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -169,12 +173,15 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu : undefined; const fastMode = fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; - const settings = { - disableAllHooks: true, - ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), - ...(fastMode ? { fastMode: true } : {}), - ...(ultracode ? { ultracode: true } : {}), - }; + const settings = applyClaudeProxySettings( + { + disableAllHooks: true, + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(ultracode ? { ultracode: true } : {}), + }, + proxy, + ); const settingsJson = yield* encodeJsonForOperation( operation, settings, @@ -192,6 +199,14 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ), ) : cwd; + const settingsArgument = + proxy === undefined + ? settingsJson + : yield* writeClaudeProxySettingsFile(settings, claudeEnvironment, workingDirectory).pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to write proxy settings"), + ), + ); const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", @@ -205,7 +220,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection), ...(cliEffort ? ["--effort", cliEffort] : []), "--settings", - settingsJson, + settingsArgument, ...(investigate ? [ "--tools", diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..c241a2972d254 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,6 +1,10 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ServerConfigStreamEvent, + UsageLimitSourceId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -83,3 +87,50 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); + +it("reports the routing proxy's accounts unless a hub already points at it or owns its id", () => { + const hub = { + kind: "cliproxy" as const, + url: "http://proxy:8317/", + managementKey: "hub-key", + enabled: true, + }; + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerProxy: { url: "http://proxy:8317/v1", apiKey: "sk", managementKey: "mgmt" }, + }; + + expect(UsageLimitSources.effectiveUsageLimitSources(settings)).toEqual({ + [UsageLimitSources.PROVIDER_PROXY_SOURCE_ID]: { + kind: "cliproxy", + label: "Proxy", + url: "http://proxy:8317", + managementKey: "mgmt", + enabled: true, + }, + }); + for (const unreadableHub of [ + { ...hub, managementKey: "" }, + { ...hub, enabled: false }, + ]) { + expect( + UsageLimitSources.effectiveUsageLimitSources({ + ...settings, + usageLimitSources: { [UsageLimitSourceId.make("hub")]: unreadableHub }, + })[UsageLimitSources.PROVIDER_PROXY_SOURCE_ID]?.managementKey, + ).toBe("mgmt"); + } + for (const listed of [ + { [UsageLimitSourceId.make("hub")]: hub }, + { + [UsageLimitSources.PROVIDER_PROXY_SOURCE_ID]: { + ...hub, + url: "https://other.example.test", + }, + }, + ]) { + expect( + UsageLimitSources.effectiveUsageLimitSources({ ...settings, usageLimitSources: listed }), + ).toBe(listed); + } +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7a068cde85f6c..56349289798b8 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -8,6 +8,7 @@ import { type UsageLimitSourceId, type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; +import { normalizeProviderProxyUrl } from "@t3tools/provider-core/server/providerProxy"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -46,6 +47,37 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } +export const PROVIDER_PROXY_SOURCE_ID = "provider-proxy" as UsageLimitSourceId; + +/** The routing proxy reports its accounts without a separate hub entry, unless one already points at it. */ +export function effectiveUsageLimitSources( + settings: ServerSettings, +): Readonly> { + const url = normalizeProviderProxyUrl(settings.providerProxy.url); + const { managementKey } = settings.providerProxy; + if (url === "" || managementKey === "") return settings.usageLimitSources; + const alreadyListed = Object.values(settings.usageLimitSources).some( + (source) => + source.enabled && + source.managementKey !== "" && + normalizeProviderProxyUrl(source.url) === url, + ); + // A hand-written source may already use the synthetic id; it keeps the slot. + if (alreadyListed || PROVIDER_PROXY_SOURCE_ID in settings.usageLimitSources) { + return settings.usageLimitSources; + } + return { + ...settings.usageLimitSources, + [PROVIDER_PROXY_SOURCE_ID]: { + kind: "cliproxy", + label: "Proxy", + url, + managementKey, + enabled: true, + }, + }; +} + /** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; @@ -89,9 +121,9 @@ export const make = Effect.gen(function* () { const settings = yield* settingsService.getSettings.pipe( Effect.orElseSucceed((): ServerSettings | null => null), ); - const entries = Object.entries(settings?.usageLimitSources ?? {}).filter( - ([, config]) => config.enabled, - ); + const entries = Object.entries( + settings === null ? {} : effectiveUsageLimitSources(settings), + ).filter(([, config]) => config.enabled); const snapshots = yield* Effect.forEach( entries, ([id, config]) => readSource(id as UsageLimitSourceId, config), @@ -107,7 +139,7 @@ export const make = Effect.gen(function* () { const settings = yield* settingsService.getSettings.pipe( Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), ); - const config = settings.usageLimitSources[input.sourceId]; + const config = effectiveUsageLimitSources(settings)[input.sourceId]; if (!config?.enabled || !config.managementKey) { return yield* new UsageLimitSourceError({ detail: "The usage limit source is missing or disabled.", @@ -121,7 +153,7 @@ export const make = Effect.gen(function* () { }, refreshLock.withPermits(1)); yield* settingsService.streamChanges.pipe( - Stream.map((settings) => settings.usageLimitSources), + Stream.map(effectiveUsageLimitSources), Stream.changes, Stream.runForEach(() => refresh), Effect.forkScoped, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 21fe0fad2d086..96dcb0b7bf8dc 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -18,6 +18,7 @@ import * as Result from "effect/Result"; import { Fragment, useState, type ReactElement, type ReactNode } from "react"; import { isProviderDriverKind, + PROXY_ROUTED_DRIVERS, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -256,6 +257,51 @@ export function nextProviderEnvironmentWithFieldValue( return next; } +function proxyBlockedReason(instance: ProviderInstanceConfig): string | undefined { + if (instance.driver === "codex" && readCodexSetupMode(instance.config) === "managed") { + return "T3-managed ChatGPT sign-in can't use a proxy."; + } + const serverUrl = (instance.config as { readonly serverUrl?: unknown } | undefined)?.serverUrl; + if (instance.driver === "opencode" && typeof serverUrl === "string" && serverUrl.trim() !== "") { + return "An external OpenCode server can't use this environment's proxy."; + } + return undefined; +} + +function ProviderProxyRow(props: { + readonly instanceId: ProviderInstanceId; + readonly instance: ProviderInstanceConfig; + readonly proxyConfigured: boolean; + readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; +}) { + const enabled = props.instance.useProxy === true; + const blocked = proxyBlockedReason(props.instance); + const switchId = `provider-instance-${props.instanceId}-use-proxy`; + return ( + Use proxy} + description={ + blocked ?? + (props.proxyConfigured + ? "Send this provider's requests through the proxy and its accounts." + : "Add a proxy URL under Proxy in these settings first.") + } + control={ + { + const { useProxy: _previous, ...rest } = props.instance; + props.onUpdate(useProxy ? { ...rest, useProxy: true } : rest); + }} + /> + } + /> + ); +} + export function nextProviderEnvironmentWithFieldSecret( environment: ReadonlyArray | undefined, field: ProviderEnvironmentField, @@ -655,6 +701,8 @@ interface ProviderInstanceCardProps { readonly environmentId?: EnvironmentId | undefined; readonly secretSources?: ReadonlyArray; readonly projectOverride?: ProjectOverrideControl | undefined; + /** Whether this environment has a proxy URL for `useProxy` to route through. */ + readonly proxyConfigured?: boolean | undefined; readonly acpProjects?: | ReadonlyArray<{ readonly id: ProjectId; @@ -673,6 +721,7 @@ const NO_CUSTOM_MODELS: ReadonlyArray = []; export function ProviderInstanceCard({ instanceId, instance, + proxyConfigured = false, driverOption, liveProvider, mode, @@ -1297,6 +1346,22 @@ export function ProviderInstanceCard({ ) : null} + {PROXY_ROUTED_DRIVERS.has(instance.driver) ? ( + + + + ) : null} + ) => + updateSettings({ providerProxy: patch }); + + return ( + update({ url: "", apiKey: "", managementKey: "" })} + > + Remove proxy + + ) : null + } + > + Proxy URL} + description="A CLIProxyAPI server. Turn on Use proxy for a Claude, Codex, or OpenCode provider to send its requests here." + control={ + update({ url: url.trim() })} + /> + } + /> + API key} + description="One of the proxy's API keys. Leave empty if the proxy has none." + control={ +
+ update({ apiKey: apiKey.trim() })} + /> + {proxy.apiKey !== "" && !readOnly ? ( + + ) : null} +
+ } + /> + Management key} + description="Optional. Shows the proxy's accounts under Usage → Limits." + control={ +
+ update({ managementKey: managementKey.trim() })} + /> + {proxy.managementKey !== "" && !readOnly ? ( + + ) : null} +
+ } + /> +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index b5fb648ff97c2..81d4be1efbac3 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -98,6 +98,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ExpandableText } from "./ExpandableText"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { ProviderProxySettings } from "./ProviderProxySettings"; import { useSecretSources } from "./SecretSourceInput"; import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; @@ -357,7 +358,8 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { } else if ( !target.scoped && (searchTargetId === searchableSetting("provider-health-check-interval").id || - searchTargetId === searchableSetting("usage-providers").id) && + searchTargetId === searchableSetting("usage-providers").id || + searchTargetId === searchableSetting("provider-proxy").id) && !selectedEnvironmentCanRenderSettings && searchableEnvironmentId !== undefined ) { @@ -932,6 +934,7 @@ export function EnvironmentProviderSettings({ canWriteSettings={canWriteSettings} liveProvider={serverProviders.find((candidate) => candidate.instanceId === row.instanceId)} isUpdateDispatching={updatingProviderInstanceIds.has(row.instanceId)} + proxyConfigured={settings.providerProxy.url !== ""} showProjectOverride={showProjectOverride} projectOverrideValue={ showProjectOverride @@ -1070,6 +1073,13 @@ export function EnvironmentProviderSettings({
+ +