From 40e6ad38da19df42e92f04773c955ded403f2102 Mon Sep 17 00:00:00 2001 From: saphid Date: Fri, 30 Oct 2026 14:11:09 +1100 Subject: [PATCH 01/26] feat(server): route Claude, Codex, and OpenCode through a CLIProxyAPI proxy Adds a per-environment proxy connection (Settings -> Providers -> Proxy) and a Use proxy switch on Claude, Codex, and OpenCode instances. Routing applies at harness spawn only; Codex pins thread start and resume to the t3_proxy model provider. A proxy management key feeds Usage -> Limits automatically. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.ts | 8 +- .../Adapters/CodexAdapterV2.test.ts | 76 +++++++++++++++ .../Adapters/CodexAdapterV2.ts | 38 +++++++- apps/server/src/provider/ClaudeProvider.ts | 48 ++++++---- apps/server/src/provider/CodexProvider.ts | 24 ++++- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../src/provider/Drivers/ClaudeHome.test.ts | 23 +++++ .../server/src/provider/Drivers/ClaudeHome.ts | 25 +++++ .../src/provider/Drivers/CodexDriver.ts | 28 +++++- .../provider/ProviderInstanceRegistry.test.ts | 33 +++++++ .../src/provider/ProviderInstanceRegistry.ts | 51 ++++++++-- .../ProviderInstanceRegistryHydration.test.ts | 22 +++++ .../ProviderInstanceRegistryHydration.ts | 23 ++++- .../ProviderInstanceRegistryMutator.ts | 11 ++- .../src/provider/codexLaunchArgs.test.ts | 43 ++++++++- apps/server/src/provider/codexLaunchArgs.ts | 32 +++++++ apps/server/src/serverSettings.test.ts | 13 ++- apps/server/src/settingsSecrets.ts | 10 ++ .../src/usage/UsageLimitSources.test.ts | 33 ++++++- apps/server/src/usage/UsageLimitSources.ts | 36 ++++++- .../settings/ProviderInstanceCard.tsx | 65 +++++++++++++ .../settings/ProviderProxySettings.tsx | 93 +++++++++++++++++++ .../settings/ProviderSettingsPanel.tsx | 12 +++ .../src/components/settings/settingsSearch.ts | 9 ++ docs/user/install.md | 13 +++ docs/user/usage.md | 5 +- packages/contracts/src/providerInstance.ts | 9 ++ packages/contracts/src/settings.ts | 20 +++- packages/provider-core/package.json | 4 + packages/provider-core/src/server/driver.ts | 3 + .../provider-core/src/server/providerProxy.ts | 19 ++++ .../OpenCodeRuntime.environment.test.ts | 27 ++++++ .../src/server/OpenCodeRuntime.ts | 47 ++++++++++ .../provider-opencode/src/server/adapter.ts | 5 +- .../provider-opencode/src/server/driver.ts | 15 ++- 35 files changed, 871 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/settings/ProviderProxySettings.tsx create mode 100644 packages/provider-core/src/server/providerProxy.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 496a0b2e77d0f..ecb1e2baeac9a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -150,6 +150,7 @@ 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, claudeSignedOutMessage, makeClaudeEnvironment, resolveClaudeHomePath, @@ -11206,14 +11207,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; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c20994423f3e9..bda360e227d1b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -66,6 +66,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 CodexAdapterV2 from "./CodexAdapterV2.ts"; import { withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -1086,6 +1087,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.void, onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], + modelProvider?: string, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1131,6 +1133,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, + modelProvider, clientFactory, crypto: yield* Crypto.Crypto, fileSystem, @@ -2259,6 +2262,79 @@ 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, + 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 51a686e270389..6adcecd8bd70f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -116,7 +116,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"; @@ -1322,15 +1327,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 @@ -1547,7 +1556,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; @@ -1571,8 +1586,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 ?? "", @@ -1581,7 +1601,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, @@ -1598,6 +1619,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 crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; @@ -2964,6 +2987,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)), ), @@ -6784,6 +6810,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( client.request( "thread/start", codexThreadRuntimeParams({ + modelProvider: adapterOptions.modelProvider, mcpSession, modelSelection: threadInput.modelSelection, runtimePolicy: threadInput.runtimePolicy, @@ -6816,6 +6843,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( threadId: nativeThreadId, excludeTurns: true, ...codexThreadRuntimeParams({ + modelProvider: adapterOptions.modelProvider, mcpSession: yield* readMcpSession( threadInput.threadId ?? threadInput.providerThread.appThreadId, ), @@ -7602,6 +7630,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, @@ -7654,6 +7683,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 5bc82a41d4eda..9ead818e886af 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -40,6 +40,10 @@ import { hasClaudeManagedMcpConfig, makeClaudeEnvironment } from "./Drivers/Clau import { discoverClaudeSkills } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + PROXY_AUTH_METADATA, + PROXY_LIMITS_MESSAGE, +} from "@t3tools/provider-core/server/providerProxy"; import { type ClaudeScopedLimitNames, claudeUsageResponseToLimits, @@ -461,6 +465,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, @@ -626,25 +631,32 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const authMetadata = - claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }) ?? - apiProviderAuthMetadata(capabilities.apiProvider) ?? - (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined); + const authMetadata = routedThroughProxy + ? PROXY_AUTH_METADATA + : (claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }) ?? + apiProviderAuthMetadata(capabilities.apiProvider) ?? + (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined)); 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 2b2d5501881b4..7d69d7cd64489 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -44,6 +44,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, @@ -696,6 +700,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, managedAuth?: ServerProvider["auth"], + routedThroughProxy = false, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -728,7 +733,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)), @@ -780,9 +785,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..53d56756b2be1 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( @@ -272,6 +278,7 @@ export const ClaudeDriver: ProviderDriver diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index b9eb3b806ce0a..b9c2e96e081e2 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -6,12 +6,35 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { + applyClaudeProxyEnvironment, 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", + }); + }); +}); + it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bd81aa10bce7a..1c083f5e7174a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -5,6 +5,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import type { ProviderProxyRoute } from "@t3tools/provider-core/server/providerProxy"; import * as HostProcess from "@t3tools/shared/HostProcess"; const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); @@ -42,6 +43,30 @@ 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", +] 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: proxy.apiKey, + // 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; +}; + /** 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 bbd910cf5d4a9..273306bcaa96c 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, @@ -149,7 +157,12 @@ export const CodexDriver: ProviderDriver< const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; 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({ @@ -171,7 +184,7 @@ export const CodexDriver: ProviderDriver< ), ); const effectiveConfig = { - ...config, + ...routed.config, enabled, binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", @@ -198,6 +211,7 @@ export const CodexDriver: ProviderDriver< environment, enabled, config, + proxy, }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, ).pipe( @@ -217,7 +231,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 de8fc1deae452..5d5e16a34ae4f 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -642,4 +642,37 @@ 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)), + ); }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 3bbbe2900b9f2..56edd59107418 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 ProviderDriverKind, type ServerProvider, } from "@t3tools/contracts"; @@ -38,7 +38,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface LiveEntry { readonly instance: ProviderInstance; readonly scope: Scope.Closeable; - readonly entry: ProviderInstanceConfig; + readonly entry: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeEntry; } interface RegistryState { @@ -47,8 +47,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; +}; /** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { @@ -64,7 +80,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly state: RegistryState; readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; -}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { +}): (( + configMap: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap, +) => Effect.Effect) => { const { state, driversById, parentScope } = input; - return (configMap: ProviderInstanceConfigMap) => + return (configMap: ProviderInstanceRegistryMutator.ProviderInstanceRuntimeMap) => Effect.gen(function* () { const previousEntries = yield* Ref.get(state.entries); const previousUnavailable = yield* Ref.get(state.unavailable); @@ -253,7 +286,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"]; @@ -314,7 +347,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 cb778f1638a22..52560781213e6 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 * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -42,9 +45,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) { @@ -73,7 +81,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"); @@ -157,7 +172,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 51322826230d8..66e3245dc2d28 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 } from "@t3tools/contracts"; +import type { ProviderInstanceConfig } 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; } export class ProviderInstanceRegistryMutator extends Context.Service< diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts index ce18c8f3f1bd5..55d793cc97727 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,40 @@ describe("codexExecLaunchArgs", () => { ]); }); }); + +describe("applyCodexProxyRuntime", () => { + const proxyProvider = (keyField: string) => + `model_providers.t3_proxy={name="T3 proxy",base_url="http://proxy:8317/v1",${keyField}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('env_key="T3_PROXY_KEY",'), + ]); + NodeAssert.deepStrictEqual(codexExecLaunchArgs(config.launchArgs).slice(-2), [ + "-c", + proxyProvider('env_key="T3_PROXY_KEY",'), + ]); + NodeAssert.deepStrictEqual(environment, { PATH: "/bin", T3_PROXY_KEY: "sk-proxy" }); + }); + + it("sends no key to a proxy that has none", () => { + const { config } = applyCodexProxyRuntime( + { launchArgs: "" }, + {}, + { url: "http://proxy:8317", apiKey: "" }, + ); + + NodeAssert.deepStrictEqual(codexAppServerArgs(config.launchArgs).at(-1), proxyProvider("")); + }); +}); diff --git a/apps/server/src/provider/codexLaunchArgs.ts b/apps/server/src/provider/codexLaunchArgs.ts index 33def5ce9a61a..cc67972341b5d 100644 --- a/apps/server/src/provider/codexLaunchArgs.ts +++ b/apps/server/src/provider/codexLaunchArgs.ts @@ -1,3 +1,7 @@ +import { + providerProxyApiUrl, + 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 +11,34 @@ 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 keyField = proxy.apiKey === "" ? "" : `env_key="${CODEX_PROXY_KEY_ENV}",`; + const proxyArgs = [ + `model_provider="${CODEX_PROXY_MODEL_PROVIDER}"`, + `model_providers.${CODEX_PROXY_MODEL_PROVIDER}={name="T3 proxy",base_url=${JSON.stringify(providerProxyApiUrl(proxy))},${keyField}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]: proxy.apiKey }; + // 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 8b7d81859b70d..e863046af30ff 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1246,7 +1246,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; @@ -1278,6 +1278,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", @@ -1297,6 +1302,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); @@ -1312,6 +1319,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); @@ -1362,6 +1370,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); @@ -1374,6 +1384,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 dff6b96f52a1b..c08c44acbabfa 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, @@ -33,6 +34,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"); @@ -146,6 +148,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( @@ -174,6 +183,7 @@ export function mapSettingsSecrets( providerInstances, sourceControlHosts, usageLimitSources, + providerProxy, easDeviceHosts, simlockDeviceHosts, }; diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..56ca34f8e5d16 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,30 @@ 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", () => { + 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, + }, + }); + const listed = { [UsageLimitSourceId.make("hub")]: hub }; + 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..dc8d1853f3dc0 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,31 @@ 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) => normalizeProviderProxyUrl(source.url) === url, + ); + if (alreadyListed) 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 +115,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 +133,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 +147,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 88361eac68bc8..c3af4e23eccdb 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 { useState, type ReactElement, type ReactNode } from "react"; import { isProviderDriverKind, + PROXY_ROUTED_DRIVERS, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -247,6 +248,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); + }} + /> + } + /> + ); +} + function ProviderEnvironmentFieldRow(props: { readonly field: ProviderEnvironmentField; readonly variable: ProviderInstanceEnvironmentVariable | undefined; @@ -585,6 +631,8 @@ interface ProviderInstanceCardProps { readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; readonly environmentId?: EnvironmentId | undefined; 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; @@ -602,6 +650,7 @@ const NO_CUSTOM_MODELS: ReadonlyArray = []; export function ProviderInstanceCard({ instanceId, instance, + proxyConfigured = false, driverOption, liveProvider, mode, @@ -1216,6 +1265,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() })} + /> + } + /> + Management key} + description="Optional. Shows the proxy's accounts under Usage → Limits." + control={ + update({ managementKey: managementKey.trim() })} + /> + } + /> + + ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 9dae72e98ff50..54fb84fa5bda2 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 { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection"; @@ -931,6 +932,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 @@ -1069,6 +1071,13 @@ export function EnvironmentProviderSettings({ + +