Skip to content

Commit 670fea0

Browse files
committed
feat: add PLUS_API_KEY validation and Plus host switching.
1 parent a8c9f8b commit 670fea0

20 files changed

Lines changed: 450 additions & 238 deletions

‎docs/configuration.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,6 @@ PLUS 独立配置文件为 `~/.deepcode-plus/settings.json`:
286286

287287
`subscriptionPlan` 支持 `default`、`on`、`off`;缺失或非法值按 `default` 处理。普通通道继续使用上文配置层级合并后的 API key 和 base URL。
288288

289-
- `default`:未配置 PLUS key 时使用普通通道;否则每次创建或回复会话前,用 PLUS key 请求 `GET https://deepcode.vegamo.cn/plugin/openai/models`。200 表示 `full ability`,使用 PLUS;401/403 表示 `api only`,使用普通通道(普通 key 缺失也不回退 PLUS)。其他 HTTP 状态、网络异常或 3 秒超时表示 `unknown`:优先普通 key,未配置普通 key 时使用 PLUS。
290-
- `on`:直接使用 PLUS key 和 `https://deepcode.vegamo.cn/plugin/openai`,不执行订阅检查。缺少 PLUS key 时明确报错,不回退普通通道。
289+
- `default`:如果开通了DeepCode Plus订阅,则相当于`on`,否则相当于`off`。
290+
- `on`:直接使用 PLUS key,不执行订阅检查。缺少 PLUS key 时明确报错,不回退普通通道。
291291
- `off`:固定使用普通通道,不执行订阅检查。

‎docs/configuration_en.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,6 @@ Configure PLUS separately in `~/.deepcode-plus/settings.json`:
285285

286286
`subscriptionPlan` accepts `default`, `on`, or `off`; missing or invalid values use `default`. The regular connection retains the user/project/environment precedence described above.
287287

288-
- `default`: Without a PLUS key, use the regular connection. Otherwise, before each session creation or reply, request `GET https://deepcode.vegamo.cn/plugin/openai/models` with the PLUS key. HTTP 200 means `full ability` and selects PLUS. HTTP 401/403 means `api only` and selects the regular connection, even if its key is missing. Other HTTP statuses, network errors, and a 3-second timeout mean `unknown`: prefer the regular key if configured, otherwise use PLUS.
289-
- `on`: Use the PLUS key with `https://deepcode.vegamo.cn/plugin/openai` directly, without a subscription check. A missing PLUS key produces an explicit error without falling back.
288+
- `default`: If you have a DeepCode Plus subscription, it's considered `on`; otherwise, it's considered `off`.
289+
- `on`: Use the PLUS key, without a subscription check. A missing PLUS key produces an explicit error without falling back.
290290
- `off`: Always use the regular connection without checking the subscription.

‎packages/cli/src/ui/views/App.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
256256
// warmup (fire-and-forget inside createOpenAIClient) starts before the
257257
// user sends their first prompt.
258258
useEffect(() => {
259-
clientFactory();
259+
try {
260+
clientFactory();
261+
} catch (error) {
262+
setErrorLine(error instanceof Error ? error.message : String(error));
263+
}
260264
}, [clientFactory]);
261265

262266
/**

‎packages/core/src/common/plus-subscription.ts‎

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,63 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
14
import { fetch as undiciFetch } from "undici";
2-
import { readDeepcodePlusSettings, type DeepcodePlusSettings } from "../settings";
35
import type { CreateOpenAIClient, OpenAIClientResult } from "./tool-types";
46

5-
export const DEEPCODE_PLUS_BASE_URL = "https://deepcode.vegamo.cn/plugin/openai";
7+
export const DEEPCODE_PLUS_LEGACY_HOST = "https://deepcode.vegamo.cn";
8+
export const DEEPCODE_PLUS_HOST = "https://www.deepcodeplus.com";
9+
10+
/** Undefined means unconfigured; all explicitly configured values must be valid. */
11+
export function normalizePlusApiKey(
12+
value: unknown,
13+
settingsPath = "~/.deepcode-plus/settings.json"
14+
): string | undefined {
15+
if (value === undefined) return undefined;
16+
const key = typeof value === "string" ? value.trim() : "";
17+
const length = Array.from(key.slice(3)).length;
18+
if (!key.startsWith("sk-") || (length !== 24 && length !== 26)) {
19+
throw new Error(`Invalid PLUS_API_KEY in ${settingsPath}: expected "sk-" followed by 24 or 26 characters.`);
20+
}
21+
return key;
22+
}
23+
24+
export function resolvePlusHost(apiKey?: string): string {
25+
const key = normalizePlusApiKey(apiKey);
26+
return key && Array.from(key.slice(3)).length === 26 ? DEEPCODE_PLUS_HOST : DEEPCODE_PLUS_LEGACY_HOST;
27+
}
28+
29+
export function getDeepcodePlusSettingsPath(): string {
30+
return path.join(os.homedir(), ".deepcode-plus", "settings.json");
31+
}
32+
33+
export type SubscriptionPlan = "default" | "on" | "off";
34+
35+
export type DeepcodePlusSettings = {
36+
apiKey?: string;
37+
subscriptionPlan: SubscriptionPlan;
38+
};
39+
40+
export function readDeepcodePlusSettings(settingsPath: string = getDeepcodePlusSettingsPath()): DeepcodePlusSettings {
41+
let settings: { env?: { PLUS_API_KEY?: unknown }; subscriptionPlan?: unknown } | null;
42+
try {
43+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
44+
} catch {
45+
return { subscriptionPlan: "default" };
46+
}
47+
return {
48+
apiKey: normalizePlusApiKey(settings?.env?.PLUS_API_KEY, settingsPath),
49+
subscriptionPlan:
50+
settings?.subscriptionPlan === "on" || settings?.subscriptionPlan === "off"
51+
? settings.subscriptionPlan
52+
: "default",
53+
};
54+
}
55+
56+
export function readDeepcodePlusApiKey(settingsPath: string = getDeepcodePlusSettingsPath()): string | undefined {
57+
return readDeepcodePlusSettings(settingsPath).apiKey;
58+
}
59+
60+
export const DEEPCODE_PLUS_BASE_URL = `${DEEPCODE_PLUS_LEGACY_HOST}/plugin/openai`;
661
export type PlusSubscriptionStatus = "api only" | "full ability" | "unknown";
762
export type OpenAIConnection = {
863
apiKey?: string;
@@ -21,12 +76,14 @@ export function resolveOpenAIConnection(
2176
subscriptionPlan: DeepcodePlusSettings["subscriptionPlan"] = "default",
2277
status: PlusSubscriptionStatus = "unknown"
2378
): OpenAIConnection {
79+
plusApiKey = normalizePlusApiKey(plusApiKey);
80+
const baseURL = `${resolvePlusHost(plusApiKey)}/plugin/openai`;
2481
const regular = { apiKey: settings.apiKey, baseURL: settings.baseURL, usingPlus: false };
2582
if (subscriptionPlan === "off") return regular;
2683
if (subscriptionPlan === "on" && !plusApiKey) {
2784
return {
2885
apiKey: undefined,
29-
baseURL: DEEPCODE_PLUS_BASE_URL,
86+
baseURL,
3087
usingPlus: false,
3188
configurationError:
3289
"PLUS_API_KEY not found. Please configure env.PLUS_API_KEY in ~/.deepcode-plus/settings.json.",
@@ -37,7 +94,7 @@ export function resolveOpenAIConnection(
3794
(!plusApiKey || status === "api only" || (status === "unknown" && settings.apiKey))
3895
)
3996
return regular;
40-
return { apiKey: plusApiKey, baseURL: DEEPCODE_PLUS_BASE_URL, usingPlus: true };
97+
return { apiKey: plusApiKey, baseURL, usingPlus: true };
4198
}
4299

43100
type ProbeFetch = (
@@ -57,12 +114,14 @@ export async function checkPlusSubscription(
57114
timeoutMs = 3000
58115
): Promise<PlusSubscriptionStatus> {
59116
signal?.throwIfAborted();
117+
apiKey = normalizePlusApiKey(apiKey)!;
118+
const baseURL = `${resolvePlusHost(apiKey)}/plugin/openai`;
60119
const controller = new AbortController();
61120
const abort = () => controller.abort(signal?.reason);
62121
signal?.addEventListener("abort", abort, { once: true });
63122
const timer = setTimeout(() => controller.abort(), timeoutMs);
64123
try {
65-
const response = await fetcher(`${DEEPCODE_PLUS_BASE_URL}/models`, {
124+
const response = await fetcher(`${baseURL}/models`, {
66125
method: "GET",
67126
headers: { Authorization: `Bearer ${apiKey}` },
68127
signal: controller.signal,
@@ -102,7 +161,8 @@ export function withPlusSubscription(
102161
return Object.assign(() => buildClient(prepared ?? resolve(readSettings(), "unknown")), {
103162
prepare: async (signal?: AbortSignal) => {
104163
signal?.throwIfAborted();
105-
const plus = readSettings();
164+
const settings = readSettings();
165+
const plus = { ...settings, apiKey: normalizePlusApiKey(settings.apiKey) };
106166
const status =
107167
plus.subscriptionPlan === "default" && plus.apiKey ? await checkSubscription(plus.apiKey, signal) : "unknown";
108168
signal?.throwIfAborted();

‎packages/core/src/common/telemetry.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new";
1+
import { resolvePlusHost } from "./plus-subscription";
22
const DEFAULT_REPORT_TIMEOUT_MS = 3000;
33

44
export type NewPromptReportOptions = {
55
enabled: boolean;
66
machineId?: string;
7+
plusApiKey?: string;
78
timeoutMs?: number;
89
};
910

@@ -16,11 +17,12 @@ export function reportNewPrompt(options: NewPromptReportOptions): void {
1617
return;
1718
}
1819

20+
const url = `${resolvePlusHost(options.plusApiKey)}/api/plugin/new`;
1921
const timeoutMs = options.timeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS;
2022
const controller = new AbortController();
2123
const timeout = setTimeout(() => controller.abort(), timeoutMs);
2224

23-
void fetch(DEFAULT_NEW_PROMPT_API_URL, {
25+
void fetch(url, {
2426
method: "POST",
2527
headers: {
2628
"Content-Type": "application/json",

‎packages/core/src/session.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,8 +2166,8 @@ ${agentInstructions}
21662166
}
21672167

21682168
private reportNewPrompt(): void {
2169-
const { machineId, telemetryEnabled } = this.createOpenAIClient();
2170-
reportNewPrompt({ enabled: telemetryEnabled ?? true, machineId });
2169+
const { machineId, telemetryEnabled, plusApiKey } = this.createOpenAIClient();
2170+
reportNewPrompt({ enabled: telemetryEnabled ?? true, machineId, plusApiKey });
21712171
}
21722172

21732173
interruptActiveSession(): void {

‎packages/core/src/settings.ts‎

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -775,50 +775,22 @@ export const DEFAULT_BASE_URL = "https://api.deepseek.com";
775775
// Settings file I/O
776776
// ---------------------------------------------------------------------------
777777

778+
export {
779+
getDeepcodePlusSettingsPath,
780+
readDeepcodePlusSettings,
781+
readDeepcodePlusApiKey,
782+
type SubscriptionPlan,
783+
type DeepcodePlusSettings,
784+
} from "./common/plus-subscription";
785+
778786
export function getUserSettingsPath(): string {
779787
return path.join(os.homedir(), ".deepcode", "settings.json");
780788
}
781789

782-
export function getDeepcodePlusSettingsPath(): string {
783-
return path.join(os.homedir(), ".deepcode-plus", "settings.json");
784-
}
785-
786790
export function getProjectSettingsPath(projectRoot: string): string {
787791
return path.join(projectRoot, ".deepcode", "settings.json");
788792
}
789793

790-
export type SubscriptionPlan = "default" | "on" | "off";
791-
792-
export type DeepcodePlusSettings = {
793-
apiKey?: string;
794-
subscriptionPlan: SubscriptionPlan;
795-
};
796-
797-
export function readDeepcodePlusSettings(settingsPath: string = getDeepcodePlusSettingsPath()): DeepcodePlusSettings {
798-
try {
799-
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as {
800-
env?: { PLUS_API_KEY?: unknown };
801-
subscriptionPlan?: unknown;
802-
} | null;
803-
return {
804-
apiKey:
805-
typeof settings?.env?.PLUS_API_KEY === "string"
806-
? trimString(settings.env.PLUS_API_KEY) || undefined
807-
: undefined,
808-
subscriptionPlan:
809-
settings?.subscriptionPlan === "on" || settings?.subscriptionPlan === "off"
810-
? settings.subscriptionPlan
811-
: "default",
812-
};
813-
} catch {
814-
return { subscriptionPlan: "default" };
815-
}
816-
}
817-
818-
export function readDeepcodePlusApiKey(settingsPath: string = getDeepcodePlusSettingsPath()): string | undefined {
819-
return readDeepcodePlusSettings(settingsPath).apiKey;
820-
}
821-
822794
export function readSettingsFile(settingsPath: string): DeepcodingSettings | null {
823795
try {
824796
if (!fs.existsSync(settingsPath)) {

‎packages/core/src/tests/openai-client.test.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import { Models } from "openai/resources/models";
77
import { createOpenAIClientFactory, DEEPCODE_PLUS_BASE_URL, resolveOpenAIConnection } from "../common/openai-client";
88

99
test("resolveOpenAIConnection falls back to DeepCode Plus credentials", () => {
10-
const resolved = resolveOpenAIConnection({ baseURL: "https://configured.example.com" }, "sk-plus-test");
10+
const resolved = resolveOpenAIConnection(
11+
{ baseURL: "https://configured.example.com" },
12+
"sk-aaaaaaaaaaaaaaaaaaaaaaaa"
13+
);
1114

1215
assert.deepEqual(resolved, {
13-
apiKey: "sk-plus-test",
16+
apiKey: "sk-aaaaaaaaaaaaaaaaaaaaaaaa",
1417
baseURL: DEEPCODE_PLUS_BASE_URL,
1518
usingPlus: true,
1619
});
@@ -19,7 +22,7 @@ test("resolveOpenAIConnection falls back to DeepCode Plus credentials", () => {
1922
test("resolveOpenAIConnection prefers regular credentials", () => {
2023
const resolved = resolveOpenAIConnection(
2124
{ apiKey: "sk-regular-test", baseURL: "https://configured.example.com" },
22-
"sk-plus-test"
25+
"sk-aaaaaaaaaaaaaaaaaaaaaaaa"
2326
);
2427

2528
assert.deepEqual(resolved, {
@@ -49,7 +52,7 @@ test("on mode retains PLUS routing after the existing models warmup fails", asyn
4952
path.join(home, ".deepcode-plus", "settings.json"),
5053
JSON.stringify({
5154
subscriptionPlan: "on",
52-
env: { PLUS_API_KEY: "plus-warmup-test" },
55+
env: { PLUS_API_KEY: "sk-bbbbbbbbbbbbbbbbbbbbbbbbbb" },
5356
})
5457
);
5558
// Stub the SDK warmup, so no request can reach the real PLUS service.
@@ -61,8 +64,9 @@ test("on mode retains PLUS routing after the existing models warmup fails", asyn
6164
await new Promise((resolve) => setImmediate(resolve));
6265
assert.equal(warmup.mock.callCount(), 1);
6366
assert.equal(first.usingPlus, true);
64-
assert.equal(first.apiKey, "plus-warmup-test");
65-
assert.equal(first.baseURL, DEEPCODE_PLUS_BASE_URL);
67+
assert.equal(first.apiKey, "sk-bbbbbbbbbbbbbbbbbbbbbbbbbb");
68+
assert.equal(first.baseURL, "https://www.deepcodeplus.com/plugin/openai");
69+
assert.equal(first.client?.baseURL, first.baseURL);
6670
assert.equal(factory().client, first.client);
6771
assert.equal(factory().usingPlus, true);
6872
assert.equal(warmup.mock.callCount(), 1, "cached client should not warm up again");
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import * as fs from "node:fs";
4+
import * as os from "node:os";
5+
import * as path from "node:path";
6+
import {
7+
normalizePlusApiKey,
8+
resolvePlusHost,
9+
checkPlusSubscription,
10+
resolveOpenAIConnection,
11+
withPlusSubscription,
12+
} from "../common/plus-subscription";
13+
import { readDeepcodePlusSettings } from "../settings";
14+
import { reportNewPrompt } from "../common/telemetry";
15+
16+
const routes = [
17+
[undefined, "https://deepcode.vegamo.cn"],
18+
[`sk-${"a".repeat(24)}`, "https://deepcode.vegamo.cn"],
19+
[`sk-${"b".repeat(26)}`, "https://www.deepcodeplus.com"],
20+
] as const;
21+
22+
for (const [key, host] of routes) {
23+
test(`PLUS routing for ${key?.length ?? "absent"} characters`, async (t) => {
24+
assert.equal(resolvePlusHost(key), host);
25+
if (key) {
26+
assert.equal(normalizePlusApiKey(` ${key}\n`), key);
27+
assert.equal(
28+
resolveOpenAIConnection({ baseURL: "https://regular.test" }, key, "on").baseURL,
29+
`${host}/plugin/openai`
30+
);
31+
assert.equal(
32+
await checkPlusSubscription(key, undefined, async (url, options) => {
33+
assert.equal(url, `${host}/plugin/openai/models`);
34+
assert.equal(options.headers.Authorization, `Bearer ${key}`);
35+
return { status: 200 };
36+
}),
37+
"full ability"
38+
);
39+
}
40+
const fetch = t.mock.method(globalThis, "fetch", async (url: string, init: RequestInit) => {
41+
assert.equal(url, `${host}/api/plugin/new`);
42+
assert.deepEqual(init.headers, { "Content-Type": "application/json", Token: "test-machine" });
43+
return new Response("{}");
44+
});
45+
reportNewPrompt({ enabled: true, machineId: "test-machine", plusApiKey: key });
46+
assert.equal(fetch.mock.callCount(), 1);
47+
await new Promise((resolve) => setImmediate(resolve));
48+
});
49+
}
50+
51+
test("PLUS suffix is not limited to alphanumeric characters", () => {
52+
for (const suffix of ["_".repeat(24), "😀".repeat(26)]) {
53+
assert.equal(normalizePlusApiKey(`sk-${suffix}`), `sk-${suffix}`);
54+
}
55+
});
56+
57+
test("configured invalid values fail for every subscription plan without revealing the key", async (t) => {
58+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-plus-invalid-"));
59+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
60+
const file = path.join(dir, "settings.json");
61+
t.mock.method(globalThis, "fetch", async () => assert.fail("must not fetch"));
62+
for (const plan of ["default", "on", "off"] as const) {
63+
for (const value of [
64+
"",
65+
" ",
66+
null,
67+
123,
68+
{},
69+
[],
70+
"secret-invalid-key",
71+
`SK-${"a".repeat(24)}`,
72+
...[23, 25, 27].map((length) => `sk-${"a".repeat(length)}`),
73+
]) {
74+
fs.writeFileSync(file, JSON.stringify({ subscriptionPlan: plan, env: { PLUS_API_KEY: value } }));
75+
assert.throws(
76+
() => readDeepcodePlusSettings(file),
77+
(error: Error) => {
78+
assert.match(error.message, /Invalid PLUS_API_KEY.*settings.json.*24 or 26/);
79+
if (typeof value === "string" && value.trim()) assert.ok(!error.message.includes(value));
80+
return true;
81+
}
82+
);
83+
}
84+
const factory = withPlusSubscription(
85+
() => ({ apiKey: "regular", baseURL: "https://regular.test" }),
86+
() => assert.fail("must not build a client"),
87+
{
88+
readSettings: () => ({ apiKey: "invalid", subscriptionPlan: plan }),
89+
checkSubscription: async () => assert.fail("must not probe"),
90+
}
91+
);
92+
await assert.rejects(factory.prepare!(), /Invalid PLUS_API_KEY/);
93+
assert.throws(() => factory(), /Invalid PLUS_API_KEY/);
94+
}
95+
await assert.rejects(
96+
checkPlusSubscription("invalid", undefined, async () => assert.fail("must not probe")),
97+
/Invalid PLUS_API_KEY/
98+
);
99+
assert.throws(
100+
() => reportNewPrompt({ enabled: true, machineId: "test-machine", plusApiKey: "invalid" }),
101+
/Invalid PLUS_API_KEY/
102+
);
103+
});

0 commit comments

Comments
 (0)