Skip to content

Commit 672c9f5

Browse files
committed
Merge branch 'improve-plus-subscription-setting'
2 parents d370f3a + 670fea0 commit 672c9f5

27 files changed

Lines changed: 1078 additions & 219 deletions

‎docs/configuration.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,20 @@ DEEPCODE_TELEMETRY_ENABLED=0 deepcode
272272
3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}`
273273
4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}`
274274
5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode`
275+
276+
## DeepCode PLUS 订阅与 LLM 通道
277+
278+
PLUS 独立配置文件为 `~/.deepcode-plus/settings.json`:
279+
280+
```json
281+
{
282+
"subscriptionPlan": "default",
283+
"env": { "PLUS_API_KEY": "sk-..." }
284+
}
285+
```
286+
287+
`subscriptionPlan` 支持 `default`、`on`、`off`;缺失或非法值按 `default` 处理。普通通道继续使用上文配置层级合并后的 API key 和 base URL。
288+
289+
- `default`:如果开通了DeepCode Plus订阅,则相当于`on`,否则相当于`off`。
290+
- `on`:直接使用 PLUS key,不执行订阅检查。缺少 PLUS key 时明确报错,不回退普通通道。
291+
- `off`:固定使用普通通道,不执行订阅检查。

‎docs/configuration_en.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,20 @@ Applied in the following priority order (lower-numbered overridden by higher-num
271271
3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}`
272272
4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}`
273273
5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode`
274+
275+
## DeepCode PLUS subscription and LLM routing
276+
277+
Configure PLUS separately in `~/.deepcode-plus/settings.json`:
278+
279+
```json
280+
{
281+
"subscriptionPlan": "default",
282+
"env": { "PLUS_API_KEY": "sk-..." }
283+
}
284+
```
285+
286+
`subscriptionPlan` accepts `default`, `on`, or `off`; missing or invalid values use `default`. The regular connection retains the user/project/environment precedence described above.
287+
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.
290+
- `off`: Always use the regular connection without checking the subscription.

‎packages/cli/src/exec-runner.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
SessionManager,
3-
createOpenAIClient,
3+
createOpenAIClientFactory,
44
resolveCurrentSettings,
55
type AskPermissionRequest,
66
type AskPermissionScope,
@@ -72,7 +72,7 @@ export async function runExecMode(
7272
const settings = deps.resolveSettings(options.projectRoot);
7373
manager = deps.createSessionManager({
7474
projectRoot: options.projectRoot,
75-
createOpenAIClient: () => createOpenAIClient(options.projectRoot),
75+
createOpenAIClient: createOpenAIClientFactory(options.projectRoot),
7676
getResolvedSettings: () => deps.resolveSettings(options.projectRoot),
7777
renderMarkdown: (text) => text,
7878
nonInteractive: true,

‎packages/cli/src/tests/ui-utils.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,20 @@ function buildMessage(role: SessionMessage["role"], content: string, visible: bo
114114
updateTime: "2026-01-01T00:00:00.000Z",
115115
};
116116
}
117+
118+
test("buildStatusLine appends plus only for the selected PLUS connection", () => {
119+
for (const usingPlus of [undefined, false, true]) {
120+
for (const thinkingEnabled of [false, true]) {
121+
const entry = { status: "completed", activeTokens: 0, failReason: null, usingPlus } as SessionEntry;
122+
assert.equal(
123+
buildStatusLine(entry, {
124+
model: "deepseek-flash",
125+
thinkingEnabled,
126+
reasoningEffort: "max",
127+
contextWindow: 1024 * 1024,
128+
}),
129+
`status: completed · deepseek-flash${thinkingEnabled ? " max" : ""}${usingPlus ? " plus" : ""}`
130+
);
131+
}
132+
}
133+
});

‎packages/cli/src/ui/utils/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ export function buildStatusLine(
127127
}
128128
const model = settings.model.trim();
129129
if (model) {
130-
parts.push(settings.thinkingEnabled ? `${model} ${settings.reasoningEffort}` : model);
130+
const modelLabel = settings.thinkingEnabled ? `${model} ${settings.reasoningEffort}` : model;
131+
parts.push(`${modelLabel}${entry.usingPlus ? " plus" : ""}`);
131132
}
132133
if (entry.failReason) {
133134
parts.push(`fail: ${entry.failReason}`);

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
22
import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink";
33
import chalk from "chalk";
4-
import { createOpenAIClient } from "@vegamo/deepcode-core";
4+
import { createOpenAIClientFactory } from "@vegamo/deepcode-core";
55
import type { PermissionScope } from "@vegamo/deepcode-core";
66
import { type ModelConfigSelection } from "@vegamo/deepcode-core";
77
import { type PromptDraft, PromptInput, type PromptSubmission } from "./PromptInput";
@@ -115,10 +115,12 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
115115
rawModeRef.current = mode;
116116
messagesRef.current = messages;
117117

118+
const clientFactory = useMemo(() => createOpenAIClientFactory(projectRoot), [projectRoot]);
119+
118120
const sessionManager = useMemo(() => {
119121
return new SessionManager({
120122
projectRoot,
121-
createOpenAIClient: () => createOpenAIClient(projectRoot),
123+
createOpenAIClient: clientFactory,
122124
getResolvedSettings: () => resolveCurrentSettings(projectRoot),
123125
renderMarkdown: (text) => text,
124126
onAssistantMessage: (message: SessionMessage) => {
@@ -163,7 +165,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
163165
buf.set(pid, current + text.slice(0, available));
164166
},
165167
});
166-
}, [projectRoot]);
168+
}, [projectRoot, clientFactory]);
167169

168170
/**
169171
* Navigate to a sub-view.
@@ -254,8 +256,12 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
254256
// warmup (fire-and-forget inside createOpenAIClient) starts before the
255257
// user sends their first prompt.
256258
useEffect(() => {
257-
createOpenAIClient(projectRoot);
258-
}, [projectRoot]);
259+
try {
260+
clientFactory();
261+
} catch (error) {
262+
setErrorLine(error instanceof Error ? error.message : String(error));
263+
}
264+
}, [clientFactory]);
259265

260266
/**
261267
* Initialize MCP servers.

‎packages/core/src/common/openai-client.ts‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import * as os from "os";
33
import * as path from "path";
44
import OpenAI from "openai";
55
import { Agent, fetch as undiciFetch } from "undici";
6-
import { readDeepcodePlusApiKey, resolveCurrentSettings, type ReasoningEffort } from "../settings";
6+
import { readDeepcodePlusSettings, resolveCurrentSettings, type ReasoningEffort } from "../settings";
7+
import { resolveOpenAIConnection, withPlusSubscription, type OpenAIConnectionContext } from "./plus-subscription";
8+
import type { CreateOpenAIClient } from "./tool-types";
9+
export { resolveOpenAIConnection, DEEPCODE_PLUS_BASE_URL } from "./plus-subscription";
710

811
// Custom undici Agent with a 180-second keepAlive timeout. The default
912
// global fetch (undici) only keeps connections alive for 4 seconds, which
@@ -19,22 +22,17 @@ const keepAliveAgent = new Agent({ keepAliveTimeout: 180_000 });
1922
let cachedOpenAI: OpenAI | null = null;
2023
let cachedOpenAIKey = "";
2124

22-
export const DEEPCODE_PLUS_BASE_URL = "https://deepcode.vegamo.cn/plugin/openai";
23-
24-
export function resolveOpenAIConnection(
25-
settings: { apiKey?: string; baseURL: string },
26-
plusApiKey?: string
27-
): { apiKey?: string; baseURL: string } {
28-
if (settings.apiKey) {
29-
return { apiKey: settings.apiKey, baseURL: settings.baseURL };
30-
}
31-
if (plusApiKey) {
32-
return { apiKey: plusApiKey, baseURL: DEEPCODE_PLUS_BASE_URL };
33-
}
34-
return { apiKey: undefined, baseURL: settings.baseURL };
25+
export function createOpenAIClientFactory(projectRoot: string = process.cwd()): CreateOpenAIClient {
26+
return withPlusSubscription(
27+
() => resolveCurrentSettings(projectRoot),
28+
(context) => createOpenAIClient(projectRoot, context)
29+
);
3530
}
3631

37-
export function createOpenAIClient(projectRoot: string = process.cwd()): {
32+
export function createOpenAIClient(
33+
projectRoot: string = process.cwd(),
34+
context?: OpenAIConnectionContext
35+
): {
3836
client: OpenAI | null;
3937
apiKey?: string;
4038
model: string;
@@ -49,10 +47,14 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
4947
env: Record<string, string>;
5048
machineId?: string;
5149
plusApiKey?: string;
50+
usingPlus: boolean;
51+
configurationError?: string;
5252
} {
5353
const settings = resolveCurrentSettings(projectRoot);
54-
const plusApiKey = readDeepcodePlusApiKey();
55-
const connection = resolveOpenAIConnection(settings, plusApiKey);
54+
const plusSettings = context ? undefined : readDeepcodePlusSettings();
55+
const plusApiKey = context ? context.plusApiKey : plusSettings?.apiKey;
56+
const connection =
57+
context?.connection ?? resolveOpenAIConnection(settings, plusApiKey, plusSettings?.subscriptionPlan);
5658
if (!connection.apiKey) {
5759
return {
5860
client: null,
@@ -69,6 +71,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
6971
env: settings.env,
7072
machineId: getMachineId(),
7173
plusApiKey,
74+
usingPlus: connection.usingPlus,
75+
configurationError: connection.configurationError,
7276
};
7377
}
7478

@@ -89,6 +93,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
8993
env: settings.env,
9094
machineId: getMachineId(),
9195
plusApiKey,
96+
usingPlus: connection.usingPlus,
97+
configurationError: connection.configurationError,
9298
};
9399
}
94100

@@ -128,6 +134,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
128134
env: settings.env,
129135
machineId: getMachineId(),
130136
plusApiKey,
137+
usingPlus: connection.usingPlus,
138+
configurationError: connection.configurationError,
131139
};
132140
}
133141

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
4+
import { fetch as undiciFetch } from "undici";
5+
import type { CreateOpenAIClient, OpenAIClientResult } from "./tool-types";
6+
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`;
61+
export type PlusSubscriptionStatus = "api only" | "full ability" | "unknown";
62+
export type OpenAIConnection = {
63+
apiKey?: string;
64+
baseURL: string;
65+
usingPlus: boolean;
66+
configurationError?: string;
67+
};
68+
export type OpenAIConnectionContext = {
69+
connection: OpenAIConnection;
70+
plusApiKey?: string;
71+
};
72+
73+
export function resolveOpenAIConnection(
74+
settings: { apiKey?: string; baseURL: string },
75+
plusApiKey?: string,
76+
subscriptionPlan: DeepcodePlusSettings["subscriptionPlan"] = "default",
77+
status: PlusSubscriptionStatus = "unknown"
78+
): OpenAIConnection {
79+
plusApiKey = normalizePlusApiKey(plusApiKey);
80+
const baseURL = `${resolvePlusHost(plusApiKey)}/plugin/openai`;
81+
const regular = { apiKey: settings.apiKey, baseURL: settings.baseURL, usingPlus: false };
82+
if (subscriptionPlan === "off") return regular;
83+
if (subscriptionPlan === "on" && !plusApiKey) {
84+
return {
85+
apiKey: undefined,
86+
baseURL,
87+
usingPlus: false,
88+
configurationError:
89+
"PLUS_API_KEY not found. Please configure env.PLUS_API_KEY in ~/.deepcode-plus/settings.json.",
90+
};
91+
}
92+
if (
93+
subscriptionPlan === "default" &&
94+
(!plusApiKey || status === "api only" || (status === "unknown" && settings.apiKey))
95+
)
96+
return regular;
97+
return { apiKey: plusApiKey, baseURL, usingPlus: true };
98+
}
99+
100+
type ProbeFetch = (
101+
url: string,
102+
options: {
103+
method: "GET";
104+
headers: Record<string, string>;
105+
signal: AbortSignal;
106+
redirect: "manual";
107+
}
108+
) => Promise<{ status: number; body?: { cancel(): Promise<void> } | null }>;
109+
110+
export async function checkPlusSubscription(
111+
apiKey: string,
112+
signal?: AbortSignal,
113+
fetcher: ProbeFetch = undiciFetch,
114+
timeoutMs = 3000
115+
): Promise<PlusSubscriptionStatus> {
116+
signal?.throwIfAborted();
117+
apiKey = normalizePlusApiKey(apiKey)!;
118+
const baseURL = `${resolvePlusHost(apiKey)}/plugin/openai`;
119+
const controller = new AbortController();
120+
const abort = () => controller.abort(signal?.reason);
121+
signal?.addEventListener("abort", abort, { once: true });
122+
const timer = setTimeout(() => controller.abort(), timeoutMs);
123+
try {
124+
const response = await fetcher(`${baseURL}/models`, {
125+
method: "GET",
126+
headers: { Authorization: `Bearer ${apiKey}` },
127+
signal: controller.signal,
128+
redirect: "manual",
129+
});
130+
signal?.throwIfAborted();
131+
// Only HTTP status matters; release the body without requiring valid JSON.
132+
await response.body?.cancel().catch(() => {});
133+
if (response.status === 200) return "full ability";
134+
if (response.status === 401 || response.status === 403) return "api only";
135+
return "unknown";
136+
} catch {
137+
signal?.throwIfAborted();
138+
return "unknown";
139+
} finally {
140+
clearTimeout(timer);
141+
signal?.removeEventListener("abort", abort);
142+
}
143+
}
144+
145+
/** Keep the selected credentials stable for every LLM call within a turn. */
146+
export function withPlusSubscription(
147+
getSettings: () => { apiKey?: string; baseURL: string },
148+
buildClient: (context: OpenAIConnectionContext) => OpenAIClientResult,
149+
dependencies: {
150+
readSettings?: () => DeepcodePlusSettings;
151+
checkSubscription?: (apiKey: string, signal?: AbortSignal) => Promise<PlusSubscriptionStatus>;
152+
} = {}
153+
): CreateOpenAIClient {
154+
const readSettings = dependencies.readSettings ?? readDeepcodePlusSettings;
155+
const checkSubscription = dependencies.checkSubscription ?? checkPlusSubscription;
156+
let prepared: OpenAIConnectionContext | undefined;
157+
const resolve = (plus: DeepcodePlusSettings, status: PlusSubscriptionStatus): OpenAIConnectionContext => ({
158+
connection: resolveOpenAIConnection(getSettings(), plus.apiKey, plus.subscriptionPlan, status),
159+
plusApiKey: plus.apiKey,
160+
});
161+
return Object.assign(() => buildClient(prepared ?? resolve(readSettings(), "unknown")), {
162+
prepare: async (signal?: AbortSignal) => {
163+
signal?.throwIfAborted();
164+
const settings = readSettings();
165+
const plus = { ...settings, apiKey: normalizePlusApiKey(settings.apiKey) };
166+
const status =
167+
plus.subscriptionPlan === "default" && plus.apiKey ? await checkSubscription(plus.apiKey, signal) : "unknown";
168+
signal?.throwIfAborted();
169+
prepared = resolve(plus, status);
170+
},
171+
});
172+
}

0 commit comments

Comments
 (0)