Skip to content

Commit fe561e3

Browse files
saphidclaude
andcommitted
fix: address review on proxy routing
- a hand-written provider-proxy usage source keeps its id - proxied OpenCode skips Go limits, offers only routed providers, and refuses JSONC config - each proxy key can be cleared on its own Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent d6ba85f commit fe561e3

8 files changed

Lines changed: 100 additions & 31 deletions

File tree

‎apps/server/src/usage/UsageLimitSources.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () =>
8888
),
8989
);
9090

91-
it("reports the routing proxy's accounts unless a hub already points at it", () => {
91+
it("reports the routing proxy's accounts unless a hub already points at it or owns its id", () => {
9292
const hub = {
9393
kind: "cliproxy" as const,
9494
url: "http://proxy:8317/",
@@ -109,8 +109,17 @@ it("reports the routing proxy's accounts unless a hub already points at it", ()
109109
enabled: true,
110110
},
111111
});
112-
const listed = { [UsageLimitSourceId.make("hub")]: hub };
113-
expect(
114-
UsageLimitSources.effectiveUsageLimitSources({ ...settings, usageLimitSources: listed }),
115-
).toBe(listed);
112+
for (const listed of [
113+
{ [UsageLimitSourceId.make("hub")]: hub },
114+
{
115+
[UsageLimitSources.PROVIDER_PROXY_SOURCE_ID]: {
116+
...hub,
117+
url: "https://other.example.test",
118+
},
119+
},
120+
]) {
121+
expect(
122+
UsageLimitSources.effectiveUsageLimitSources({ ...settings, usageLimitSources: listed }),
123+
).toBe(listed);
124+
}
116125
});

‎apps/server/src/usage/UsageLimitSources.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ export function effectiveUsageLimitSources(
5959
const alreadyListed = Object.values(settings.usageLimitSources).some(
6060
(source) => normalizeProviderProxyUrl(source.url) === url,
6161
);
62-
if (alreadyListed) return settings.usageLimitSources;
62+
// A hand-written source may already use the synthetic id; it keeps the slot.
63+
if (alreadyListed || PROVIDER_PROXY_SOURCE_ID in settings.usageLimitSources) {
64+
return settings.usageLimitSources;
65+
}
6366
return {
6467
...settings.usageLimitSources,
6568
[PROVIDER_PROXY_SOURCE_ID]: {

‎apps/web/src/components/settings/ProviderProxySettings.tsx‎

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts";
2+
import { XIcon } from "lucide-react";
23

34
import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
45
import { Button } from "../ui/button";
@@ -61,31 +62,57 @@ export function ProviderProxySettings({
6162
title={<label htmlFor="provider-proxy-api-key-input">API key</label>}
6263
description="One of the proxy's API keys. Leave empty if the proxy has none."
6364
control={
64-
<SecretInput
65-
id="provider-proxy-api-key-input"
66-
size="sm"
67-
className={CONTROL_CLASS}
68-
value=""
69-
configured={proxy.apiKey !== ""}
70-
disabled={readOnly}
71-
onCommit={(apiKey) => update({ apiKey: apiKey.trim() })}
72-
/>
65+
<div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56">
66+
<SecretInput
67+
id="provider-proxy-api-key-input"
68+
size="sm"
69+
className="min-w-0 flex-1"
70+
value=""
71+
configured={proxy.apiKey !== ""}
72+
disabled={readOnly}
73+
onCommit={(apiKey) => update({ apiKey: apiKey.trim() })}
74+
/>
75+
{proxy.apiKey !== "" && !readOnly ? (
76+
<Button
77+
type="button"
78+
size="icon-sm"
79+
variant="ghost-destructive"
80+
onClick={() => update({ apiKey: "" })}
81+
aria-label="Clear API key"
82+
>
83+
<XIcon className="size-3.5" />
84+
</Button>
85+
) : null}
86+
</div>
7387
}
7488
/>
7589
<SettingsRow
7690
id="provider-proxy-management-key"
7791
title={<label htmlFor="provider-proxy-management-key-input">Management key</label>}
7892
description="Optional. Shows the proxy's accounts under Usage → Limits."
7993
control={
80-
<SecretInput
81-
id="provider-proxy-management-key-input"
82-
size="sm"
83-
className={CONTROL_CLASS}
84-
value=""
85-
configured={proxy.managementKey !== ""}
86-
disabled={readOnly}
87-
onCommit={(managementKey) => update({ managementKey: managementKey.trim() })}
88-
/>
94+
<div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56">
95+
<SecretInput
96+
id="provider-proxy-management-key-input"
97+
size="sm"
98+
className="min-w-0 flex-1"
99+
value=""
100+
configured={proxy.managementKey !== ""}
101+
disabled={readOnly}
102+
onCommit={(managementKey) => update({ managementKey: managementKey.trim() })}
103+
/>
104+
{proxy.managementKey !== "" && !readOnly ? (
105+
<Button
106+
type="button"
107+
size="icon-sm"
108+
variant="ghost-destructive"
109+
onClick={() => update({ managementKey: "" })}
110+
aria-label="Clear management key"
111+
>
112+
<XIcon className="size-3.5" />
113+
</Button>
114+
) : null}
115+
</div>
89116
}
90117
/>
91118
</SettingsSection>

‎docs/user/install.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,10 @@ Then turn on **Use proxy** for each Claude, Codex, or OpenCode provider that sho
205205
A provider with **Use proxy** on never falls back to its own login. If the proxy is removed, the
206206
provider shows as unavailable until you add a proxy again or turn the switch off. Codex threads
207207
started through the proxy need it to continue. T3-managed ChatGPT sign-in and external OpenCode
208-
servers cannot use a proxy. While OpenCode uses the proxy, it runs without its plugins, because
209-
plugin logins such as ChatGPT would send requests around the proxy. This needs OpenCode 1.14.49 or
210-
newer.
208+
servers cannot use a proxy. While OpenCode uses the proxy, it offers only Anthropic and OpenAI
209+
models and runs without its plugins, because other providers and plugin logins such as ChatGPT
210+
would send requests around the proxy. This needs OpenCode 1.14.49 or newer, and any
211+
`OPENCODE_CONFIG_CONTENT` must be plain JSON.
211212

212213
For provider-specific setup and accounts, see [Codex](./providers-codex.md),
213214
[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md),

‎packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ describe("applyOpenCodeProxyEnvironment", () => {
2424
// Plugins such as Codex's ChatGPT login replace fetch and would bypass the proxy.
2525
expect(environment.OPENCODE_DISABLE_DEFAULT_PLUGINS).toBe("1");
2626
expect(environment.OPENCODE_PURE).toBe("1");
27+
expect(
28+
OpenCodeRuntime.isPlainJsonOpenCodeConfig({
29+
OPENCODE_CONFIG_CONTENT: '{ "theme": "t3", // comment\n }',
30+
}),
31+
).toBe(false);
2732
expect(JSON.parse(environment.OPENCODE_CONFIG_CONTENT ?? "")).toEqual({
2833
theme: "t3",
34+
enabled_providers: ["anthropic", "openai"],
2935
provider: {
3036
anthropic: { options: { timeout: 60_000, ...proxied } },
3137
openai: { options: proxied },

‎packages/provider-opencode/src/server/OpenCodeRuntime.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ const asRecord = (value: unknown): Readonly<Record<string, unknown>> =>
8585
? (value as Record<string, unknown>)
8686
: {};
8787

88+
/** OpenCode accepts JSONC here, but the proxy override can only merge into plain JSON. */
89+
export const isPlainJsonOpenCodeConfig = (environment: NodeJS.ProcessEnv): boolean =>
90+
Option.isSome(decodeOpenCodeConfigObject(resolveOpenCodeConfigContent(environment, {})));
91+
8892
/**
8993
* Points OpenCode's built-in Anthropic and OpenAI providers at the proxy. Their model catalogs
9094
* stay intact, and the user's own config content survives around the override.
@@ -112,6 +116,8 @@ export function applyOpenCodeProxyEnvironment(
112116
};
113117
const content = encodeUnknownJsonString({
114118
...base,
119+
// Any other configured provider would send turns, and spend, around the proxy.
120+
enabled_providers: ["anthropic", "openai"],
115121
provider: { ...providers, anthropic: routed("anthropic"), openai: routed("openai") },
116122
});
117123
// A stored ChatGPT login attaches the built-in Codex plugin, whose fetch rewrites requests to

‎packages/provider-opencode/src/server/driver.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,17 @@ export const OpenCodeDriver: ProviderDriver<
203203
detail: "An external OpenCode server cannot route through this environment's proxy.",
204204
});
205205
}
206-
const processEnv = OpenCodeRuntime.applyOpenCodeProxyEnvironment(
207-
yield* mergeProviderInstanceEnvironment(environment),
208-
proxy,
209-
);
206+
const mergedEnv = yield* mergeProviderInstanceEnvironment(environment);
207+
// Dropping undecodable content would silently discard the user's agents and MCP servers.
208+
if (proxy !== undefined && !OpenCodeRuntime.isPlainJsonOpenCodeConfig(mergedEnv)) {
209+
return yield* new ProviderDriverError({
210+
driver: DRIVER_KIND,
211+
instanceId,
212+
detail:
213+
"OPENCODE_CONFIG_CONTENT must be plain JSON, without comments or trailing commas, to use the proxy.",
214+
});
215+
}
216+
const processEnv = OpenCodeRuntime.applyOpenCodeProxyEnvironment(mergedEnv, proxy);
210217
const continuationIdentity = defaultProviderContinuationIdentity({
211218
driverKind: DRIVER_KIND,
212219
instanceId,
@@ -363,6 +370,7 @@ export const OpenCodeDriver: ProviderDriver<
363370
enabled: effectiveConfig.enabled,
364371
serverUrl: effectiveConfig.serverUrl,
365372
environment: processEnv,
373+
proxied: proxy !== undefined,
366374
}),
367375
},
368376
{ concurrency: "unbounded" },

‎packages/provider-opencode/src/server/usageLimits.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
makeUnavailableUsageLimits,
1919
makeUsageLimits,
2020
} from "@t3tools/provider-core/server/usageLimits";
21+
import { PROXY_LIMITS_MESSAGE } from "@t3tools/provider-core/server/providerProxy";
2122
import * as HostProcess from "@t3tools/shared/HostProcess";
2223

2324
const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
@@ -76,8 +77,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(
7677
readonly enabled: boolean;
7778
readonly serverUrl: string;
7879
readonly environment: NodeJS.ProcessEnv;
80+
readonly proxied?: boolean;
7981
}) {
8082
const checkedAt = DateTime.formatIso(yield* DateTime.now);
83+
if (input.proxied) {
84+
return makeUnavailableUsageLimits({
85+
checkedAt,
86+
reason: "unsupported",
87+
message: PROXY_LIMITS_MESSAGE,
88+
});
89+
}
8190
const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
8291
if (!input.enabled || input.serverUrl.trim()) return unsupported;
8392

0 commit comments

Comments
 (0)