Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,21 @@ Z.AI credentials use Desktop's business-token format. Older CLI OAuth credential
are converted after successful validation on their first request; failed
validation or a concurrent login never overwrites the newer shared session.

Start Plan uses the same ZCode OAuth login as Coding Plan; there is no second
Start Plan sign-in. An existing Desktop session works without another login.
Choose `account:zai-start-plan/<model>` or `account:bigmodel-start-plan/<model>`
in `/model`, or pass it with `--model`. Start Plan requests use the shared ZCode
JWT directly, without exchanging a Coding Plan project token. The runtime still
builds and signs the requests. Switching models does not change the saved login.

Start Plan providers are available locally when the active account has a valid
ZCode JWT and profile; the server determines the account's actual grants, model
availability and remaining quota. A manual Coding Plan API key alone is not a
ZCode session. Missing, expired or rejected session tokens require the shared
ZCode login to be renewed, not an additional plan-specific login. Start Plan
requests never fall back to paid Coding Plan credentials. New requests follow
the current Desktop account; retries cannot switch accounts or tokens mid-turn.

For macOS OAuth, the CLI temporarily registers a callback receiver, checks
`state`, restores the previous `zcode://` handler and sends the callback through
stdin. The runtime resolves business and project access before publishing
Expand Down
11 changes: 11 additions & 0 deletions docs/CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,17 @@ Provider 文件中的 `config.defaultModelSelection` 决定新会话使用的模
显式配置的手动 API Key 仍使用其独立的账号选择。Z.AI 凭证统一采用桌面端的业务令牌格式;
旧版 CLI 保存的 OAuth 令牌会在首次请求验证成功后转换,验证失败或并发登录不会覆盖更新的共享会话。

Start Plan 与 Coding Plan 共用同一次 ZCode OAuth 登录,不需要第二次套餐授权;已有桌面端
会话也无需再次登录。在 `/model` 中选择 `account:zai-start-plan/<model>` 或
`account:bigmodel-start-plan/<model>`,也可以通过 `--model` 指定。Start Plan 请求直接使用
共享的 ZCode JWT,不兑换 Coding Plan 项目令牌;请求仍由官方 runtime 构建和签名。
切换模型不会改变已保存的登录状态。

本地仅在当前账号的 ZCode JWT 和资料有效时提供 Start Plan provider;具体活动授权、可用模型
和剩余额度由服务端判定。仅有手动 Coding Plan API Key 不等于已经登录 ZCode。会话缺失、
过期或被拒绝时,需要更新共享的 ZCode 登录,而不是新增套餐专属登录。Start Plan 请求不会
回退使用付费 Coding Plan 凭证。新请求跟随桌面端当前账号,同一请求重试时禁止切换账号或令牌。

macOS OAuth 会临时注册回调接收器、校验 `state`,恢复原来的 `zcode://` 处理程序,并通过
stdin 把回调交给 runtime。Runtime 先取得业务和项目访问令牌,再保存加密登录凭证与
原生默认模型;TUI 随后重新读取 provider 配置。BigModel 使用 runtime 的 localhost
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
hasRuntimeResolvedContextWindow,
hasRuntimeRecursiveWorkspacePaths,
hasRuntimeSharedOAuth,
hasRuntimeStartPlan,
hasRuntimeSqliteBusyTimeout,
hasRuntimeSqliteWriteRecovery,
hasRuntimeStreamEofFinishGuard,
Expand Down Expand Up @@ -112,6 +113,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource
|| !hasRuntimeRecursiveWorkspacePaths(runtimeSource)
|| !hasRuntimeResolvedContextWindow(runtimeSource)
|| !hasRuntimeSharedOAuth(runtimeSource)
|| (patchEnabled("start-plan-standalone") && !hasRuntimeStartPlan(runtimeSource))
|| !hasRuntimeCliSettingsFile(runtimeSource)
|| !runtimeSource.includes(".readRuntimeProjection=async()=>{let $zRuntimeProjectionBridge=await ")
|| !runtimeSource.includes('"plugin://"')
Expand Down
85 changes: 85 additions & 0 deletions scripts/runtime-start-plan-patches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { parseExpressionAt, type FunctionExpression } from "acorn";

const identifier = "[A-Za-z_$][\\w$]*";
const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))';

function incompatible(detail: string): never {
throw new Error(`ZCode runtime is incompatible with standalone Start Plan support (${detail}).`);
}

function unique<Item>(items: Item[], name: string): Item {
if (items.length !== 1) incompatible(`${name} missing or ambiguous`);
return items[0]!;
}

function escapePattern(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}

function namedFunction(runtime: string, label: string): FunctionExpression {
const name = unique([...runtime.matchAll(new RegExp(`${identifier}\\((${identifier}),"${label}"\\)`, "gu"))], label)[1]!;
const start = unique([...runtime.matchAll(new RegExp(`(?:async )?function ${escapePattern(name)}\\(`, "gu"))], label).index!;
const fn = parseExpressionAt(runtime, start, { ecmaVersion: "latest" });
if (fn.type !== "FunctionExpression") incompatible(`${label} declaration changed`);
return fn;
}

export function hasRuntimeStartPlan(runtime: string): boolean {
try {
const body = (label: string) => { const fn = namedFunction(runtime, label); return runtime.slice(fn.start, fn.end); };
return body("readStandaloneCodingPlanCatalog").includes('mode:c.mode')
&& body("readStandaloneCodingPlanCatalog").includes('c.mode==="start-plan"')
&& body("resolveStandaloneCodingPlanProvider").includes('o.mode==="individual-coding-plan"')
&& body("readStandaloneAccountProviderConfigSnapshot").includes(`${helper}.readStartPlanSession(`)
&& body("readStandaloneAccountProviderConfigSnapshot").includes('$zStartSession?.family===')
&& body("createStandaloneProviderRuntimeHeadersPort").includes(`${helper}.refreshStandaloneStartPlanRequest(`);
} catch {
return false;
}
}

export function patchRuntimeStartPlan(runtime: string): string {
if (hasRuntimeStartPlan(runtime)) return runtime;
const edits: { start: number; end: number; value: string }[] = [];
const replace = (fn: FunctionExpression, pattern: RegExp, change: (match: RegExpMatchArray) => string, name: string) => {
const match = unique([...runtime.slice(fn.start, fn.end).matchAll(pattern)], name);
edits.push({ start: fn.start + match.index!, end: fn.start + match.index! + match[0].length, value: change(match) });
};

const catalog = namedFunction(runtime, "readStandaloneCodingPlanCatalog");
replace(catalog, /c\?\.type==="zhipu-account"&&c\.mode==="individual-coding-plan"&&c\.accountType&&u/gu,
() => 'c?.type==="zhipu-account"&&(c.mode==="individual-coding-plan"||c.mode==="start-plan")&&c.accountType&&u', "catalog predicate");
replace(catalog, /\{family:c\.accountType,modelId:u,providerId:i\}/gu,
() => '{family:c.accountType,modelId:u,providerId:i,mode:c.mode}', "catalog access mode");

replace(namedFunction(runtime, "resolveStandaloneCodingPlanProvider"), /\.filter\(o=>o\.family===e\)/gu,
() => '.filter(o=>o.family===e&&o.mode==="individual-coding-plan")', "login provider selection");

const snapshot = namedFunction(runtime, "readStandaloneAccountProviderConfigSnapshot");
const [store] = snapshot.params;
if (store?.type !== "Identifier") incompatible("snapshot credential store changed");
const source = runtime.slice(snapshot.start, snapshot.end);
const providers = unique([...source.matchAll(new RegExp(`(${identifier})=(${identifier})\\.providers,`, "gu"))], "snapshot providers");
const providerMap = unique([...source.matchAll(new RegExp(`(${identifier})=new (${identifier})\\(${escapePattern(providers[1]!)}\\.map\\(`, "gu"))], "provider map");
const config = unique([...source.matchAll(new RegExp(`new (${identifier})\\(\\{access:new (${identifier})\\(\\{entitled:!0\\}\\)\\}\\)`, "gu"))], "entitlement config");
replace(snapshot, new RegExp(escapePattern(providers[0]), "gu"), () =>
`$zStartSession=${providers[2]}.providers.some($zProvider=>$zProvider.mode==="start-plan")?await ${helper}.readStartPlanSession(${store.name}):void 0,`
+ `${providers[1]}=${providers[2]}.providers.filter($zProvider=>$zProvider.mode!=="start-plan"),`, "independent Start Plan snapshot");
replace(snapshot, new RegExp(`return (${identifier})\\(${escapePattern(providers[2]!)}\\.zcodeBuiltinRevision,${escapePattern(providerMap[1]!)}\\)`, "gu"), match =>
`return ${match[1]}(${providers[2]}.zcodeBuiltinRevision,new ${providerMap[2]}([...${providerMap[1]}.entries(),`
+ `...${providers[2]}.providers.filter($zProvider=>$zProvider.mode==="start-plan").map(({family:$zFamily,providerId:$zProviderId})=>`
+ `[$zProviderId,new ${config[1]}({access:new ${config[2]}({entitled:$zStartSession?.family===$zFamily})})])]))`, "Start Plan entitlements");

const headers = namedFunction(runtime, "createStandaloneProviderRuntimeHeadersPort");
const [headerStore] = headers.params;
if (headerStore?.type !== "Identifier") incompatible("request credential store changed");
replace(headers, new RegExp(`let (${identifier})=(${identifier})\\.providerId\\.trim\\(\\),(${identifier})=\\2\\.accountAccess;`, "gu"), match =>
`${match[0]}if(${match[3]}?.mode==="start-plan")return ${helper}.refreshStandaloneStartPlanRequest(${headerStore.name},${match[2]});`, "Start Plan request branch");

let output = runtime;
for (const edit of edits.sort((first, second) => second.start - first.start)) {
output = output.slice(0, edit.start) + edit.value + output.slice(edit.end);
}
if (!hasRuntimeStartPlan(output)) incompatible("postcondition verification failed");
return output;
}
6 changes: 6 additions & 0 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runt
import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts";
import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts";
export { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts";
import { hasRuntimeStartPlan, patchRuntimeStartPlan } from "./runtime-start-plan-patches.ts";
export { hasRuntimeStartPlan, patchRuntimeStartPlan } from "./runtime-start-plan-patches.ts";
export { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts";
import { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "./runtime-browser-patches.ts";
export { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "./runtime-browser-patches.ts";
Expand Down Expand Up @@ -1975,6 +1977,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [
id: "shared-oauth", requirement: "required", apply: patchRuntimeSharedOAuth,
verify: hasRuntimeSharedOAuth
},
{
id: "start-plan-standalone", requirement: "optional", apply: patchRuntimeStartPlan,
verify: hasRuntimeStartPlan
},
{
id: "login-model-defaults",
requirement: "required",
Expand Down
1 change: 1 addition & 0 deletions src/runtime-config-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { appServerRegistryOptions } from "./app-server-auth.ts";
export { exchangeBigmodelOAuthCode } from "./bigmodel-oauth.ts";
export { browserOpenCommand } from "./browser-open.ts";
export { sharedOAuthCredentialReader, zaiBusinessAccessToken } from "./shared-oauth.ts";
export { readStartPlanSession, refreshStandaloneStartPlanRequest } from "./start-plan.ts";
export { inspectRuntimeConfiguration, writeConfigurationDoctor } from "./config-diagnostics.ts";
export { withHeadlessPromptLifecycle } from "./runtime-headless-lifecycle.ts";

Expand Down
2 changes: 1 addition & 1 deletion src/shared-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const activeProviderKey = "oauth:active_provider";
const identityKey = (provider: string) => `account-provider:${provider}:identity`;
const sourceKey = (provider: string) => `account-provider:${provider}:auth-source`;

function profileIdentity(value: string | null | undefined): string | null {
export function profileIdentity(value: string | null | undefined): string | null {
if (!value) return null;
try {
const profile = JSON.parse(value);
Expand Down
80 changes: 80 additions & 0 deletions src/start-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createHash } from "node:crypto";
import { profileIdentity } from "./shared-oauth.ts";

interface CredentialReader {
loadMany(keys: string[]): Promise<Record<string, string | null>>;
}

interface StartPlanSession {
family: "zai" | "bigmodel";
identity: string;
token: string;
}

interface StartPlanRequest {
providerId: string;
accountAccess?: { mode?: string; accountType?: string };
expectedAccountScope?: string;
rejectedProjectTokenFingerprint?: string;
abortSignal?: AbortSignal;
}

function usableToken(value: string | null | undefined): string | undefined {
const token = value?.trim().replace(/^Bearer\s+/iu, "").trim();
const parts = token?.split(".");
if (!parts || parts.length !== 3 || parts.some(part => !part)) return undefined;
try {
const claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
if (!claims || typeof claims !== "object" || Array.isArray(claims)) return undefined;
const now = Date.now() / 1000;
if (claims.exp !== undefined && (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp <= now)) return undefined;
if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || !Number.isFinite(claims.nbf) || claims.nbf > now)) return undefined;
return token;
} catch {
return undefined;
}
}

export async function readStartPlanSession(store: CredentialReader, signal?: AbortSignal): Promise<StartPlanSession | undefined> {
signal?.throwIfAborted();
const activeKey = "oauth:active_provider", tokenKey = "zcodejwttoken";
const active = await store.loadMany([activeKey]);
signal?.throwIfAborted();
const family = active[activeKey];
if (family !== "zai" && family !== "bigmodel") return undefined;
const profileKey = `oauth:${family}:user_info`;
const keys = [activeKey, tokenKey, profileKey];
const values = await store.loadMany(keys);
signal?.throwIfAborted();
const identity = profileIdentity(values[profileKey]), token = usableToken(values[tokenKey]);
if (values[activeKey] !== family || !identity || !token) return undefined;
const current = await store.loadMany(keys);
signal?.throwIfAborted();
if (keys.some(key => current[key] !== values[key])) return undefined;
return { family, identity, token };
}

export async function refreshStandaloneStartPlanRequest(store: CredentialReader, input: StartPlanRequest) {
input.abortSignal?.throwIfAborted();
const family = input.accountAccess?.accountType;
if (input.accountAccess?.mode !== "start-plan" || (family !== "zai" && family !== "bigmodel")
|| input.providerId.trim() !== `account:${family}-start-plan`) {
throw new Error("start_plan_account_required");
}
const session = await readStartPlanSession(store, input.abortSignal);
if (!session || session.family !== family) {
if (input.expectedAccountScope || input.rejectedProjectTokenFingerprint) throw new Error("project_token_scope_invalidated");
throw new Error("start_plan_login_required: Sign in to ZCode once to use both Coding Plan and Start Plan; a Coding Plan API key alone is not a ZCode session.");
}
const accountScope = createHash("sha256").update(JSON.stringify([
input.providerId.trim(), session.family, session.identity, session.token,
])).digest("hex");
if ((input.expectedAccountScope && input.expectedAccountScope !== accountScope)
|| (input.rejectedProjectTokenFingerprint && !input.expectedAccountScope)) {
throw new Error("project_token_scope_invalidated");
}
if (input.rejectedProjectTokenFingerprint === createHash("sha256").update(session.token).digest("hex")) {
throw new Error("start_plan_session_rejected: ZCode rejected the shared session; sign in to ZCode again.");
}
return { headersApplied: true, requestAuth: { apiKey: session.token, accountScope } };
}
18 changes: 13 additions & 5 deletions test/runtime/shared-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,16 @@ async function probe(body: string, options: { callback?: boolean } = {}) {
const httpFixture = `
const assert=require("node:assert/strict"),fs=require("node:fs/promises");
const env={...process.env},providerId="account:zai-individual-coding-plan";
const zcodeJwt="e30."+Buffer.from(JSON.stringify({sub:"fixture-user"})).toString("base64url")+".fixture-signature";
const store=createSharedZCodeCredentialStore({env});
const calls=[];
let businessToken="fixture-business-token",projectToken="fixture-project-token",rejectExchange=false,rejectedStatus=401,beforeIssue;
const response=(data,status=200)=>({status,body:new TextEncoder().encode(JSON.stringify({code:status===200?0:status,data}))});
const httpClient={request:async(request)=>{try{
const url=new URL(request.url);calls.push(url.pathname);
if(url.pathname==="/api/v1/oauth/cli/init")return response({authorize_url:"https://example.test/login",flow_id:"flow",expires_at:Math.ceil(Date.now()/1000)+60,poll_interval_sec:1});
if(url.pathname==="/api/v1/oauth/cli/poll/flow")return response({status:"ready",token:"fixture-zcode-jwt",user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}});
if(url.pathname==="/api/v1/oauth/token")return response({token:"fixture-zcode-jwt",user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}});
if(url.pathname==="/api/v1/oauth/cli/poll/flow")return response({status:"ready",token:zcodeJwt,user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}});
if(url.pathname==="/api/v1/oauth/token")return response({token:zcodeJwt,user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}});
if(url.pathname==="/api/auth/z/login"){
const input=JSON.parse(new TextDecoder().decode(request.body));
if(rejectExchange||input.token!=="fixture-oauth-token")return response({},401);
Expand All @@ -73,15 +74,15 @@ const httpFixture = `
throw new Error("Unexpected fixture request: "+url.pathname);
}catch(error){console.error("Fixture HTTP error:",error);throw error}}};
const resolver=()=>createCodingPlanApiKeyResolver({httpClient});
const seed=async(user="desktop-user")=>store.saveMany({"oauth:active_provider":"zai","oauth:zai:access_token":businessToken,"oauth:zai:user_info":JSON.stringify({user_id:user}),zcodejwttoken:"desktop-jwt"});
const seed=async(user="desktop-user")=>store.saveMany({"oauth:active_provider":"zai","oauth:zai:access_token":businessToken,"oauth:zai:user_info":JSON.stringify({user_id:user}),zcodejwttoken:zcodeJwt});
const input={providerId,accountAccess:{mode:"individual-coding-plan",accountType:"zai"}};
`;

for (const callback of [false, true]) test(`Z.AI ${callback ? "macOS callback" : "polling"} login publishes a token Desktop and CLI can both use`, async () => {
for (const callback of [false, true]) test(`one Z.AI ${callback ? "macOS callback" : "polling"} login authorizes Coding Plan and Start Plan`, async () => {
await probe(`
await loginZCodeCli({env,noBrowser:true,httpClient});
assert.equal(await store.load("oauth:zai:access_token"),businessToken);
assert.equal(await store.load("zcodejwttoken"),"fixture-zcode-jwt");
assert.equal(await store.load("zcodejwttoken"),zcodeJwt);
assert.deepEqual(JSON.parse(await store.load("oauth:zai:user_info")),{user_id:"fixture-user"});
const saved=await fs.readFile(store.filePath,"utf8");
assert.ok(saved.includes("enc:v1:"));
Expand All @@ -91,6 +92,13 @@ for (const callback of [false, true]) test(`Z.AI ${callback ? "macOS callback" :
calls.length=0;
const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver());
assert.equal((await headers.refreshBeforeModelRequest(input)).requestAuth.apiKey,projectToken);
const callsBeforeStart=calls.length;
assert.equal((await headers.refreshBeforeModelRequest({providerId:"account:zai-start-plan",accountAccess:{mode:"start-plan",accountType:"zai"}})).requestAuth.apiKey,zcodeJwt);
assert.equal(calls.length,callsBeforeStart);
const snapshot=await readStandaloneAccountProviderConfigSnapshot(store,env);
for(const id of [providerId,"account:zai-start-plan"]){
assert.equal(snapshot.providers.entries().find(([key])=>key===id)[1].access.entitled,true);
}
assert.ok(!calls.includes("/api/auth/z/login"));
`, { callback });
}, 15_000);
Expand Down
Loading
Loading