Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ considers a direct API key configured only when it is stored under provider ID
`zai` or `bigmodel`. An arbitrary provider ID is valid model configuration,
but as the only provider it still triggers the upstream login gate. The
display name, API format, endpoint, headers and models remain fully custom.
The launcher detects this mismatch before opening the TUI and prints the
required provider IDs instead of reporting the configuration as fully ready.

For an Anthropic-compatible endpoint:

Expand Down
32 changes: 27 additions & 5 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import { constants as osConstants, homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

import { ensureUserConfig, readConfiguredModelAccess } from "./model-access.ts";
import {
ensureUserConfig,
readConfiguredModelAccess,
type ConfiguredModelAccess
} from "./model-access.ts";
import {
classifyZaiOAuthInvocation,
runZaiOAuthLogin,
Expand Down Expand Up @@ -108,6 +112,15 @@ export function normalizeLoginArgs(args: string[]): { args: string[]; checkConfi
return { args, checkConfiguredAccess: false };
}

export function formatUnsupportedTuiProviderMessage(access: ConfiguredModelAccess): string {
return [
`Model access is configured for headless use as ${access.model}, but the upstream TUI`,
'requires a direct API key under provider ID "zai" or "bigmodel".',
`Rename the provider key and update model.main/model.lite in ${access.configPath}.`,
"See docs/CONFIGURATION.md for a compatible custom-provider example."
].join("\n");
}

function longOptionName(argument: string): string {
const separator = argument.indexOf("=");
return separator < 0 ? argument : argument.slice(0, separator);
Expand Down Expand Up @@ -433,16 +446,25 @@ export async function main(args: string[]): Promise<number> {

const login = normalizeLoginArgs(args);
const zaiOAuth = classifyZaiOAuthInvocation(args);
const configuredAccess = await readConfiguredModelAccess();
if (login.checkConfiguredAccess) {
const access = await readConfiguredModelAccess();
if (access) {
if (configuredAccess?.tuiCompatible) {
console.log(
`Model access is already configured for ${access.model}; OAuth login is not required.\n`
+ `Config: ${access.configPath}\n`
`Model access is already configured for ${configuredAccess.model}; OAuth login is not required.\n`
+ `Config: ${configuredAccess.configPath}\n`
+ "Run `zcode login --oauth` to force Z.AI OAuth."
);
return 0;
}
if (configuredAccess) {
console.error(formatUnsupportedTuiProviderMessage(configuredAccess));
return 1;
}
}

if (isTuiRuntimeInvocation(login.args) && configuredAccess && !configuredAccess.tuiCompatible) {
console.error(formatUnsupportedTuiProviderMessage(configuredAccess));
return 1;
}

if (zaiOAuth) {
Expand Down
14 changes: 13 additions & 1 deletion src/model-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ConfiguredModelAccess {
configPath: string;
model: string;
providerId: string;
tuiCompatible: boolean;
}

export interface UserConfigBootstrapResult {
Expand All @@ -32,6 +33,12 @@ export interface UserConfigBootstrapResult {

export type UserConfigRecord = Record<string, unknown>;

const tuiDirectApiKeyProviderIds = new Set(["zai", "bigmodel"]);

export function isTuiCompatibleProviderId(providerId: string): boolean {
return tuiDirectApiKeyProviderIds.has(providerId);
}

function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
Expand Down Expand Up @@ -183,5 +190,10 @@ export async function readConfiguredModelAccess(
const provider = config.provider?.[providerId];
const apiKey = provider?.options?.apiKey;
if (!provider?.models?.[modelId] || typeof apiKey !== "string" || !apiKey.trim()) return null;
return { configPath, model, providerId };
return {
configPath,
model,
providerId,
tuiCompatible: isTuiCompatibleProviderId(providerId)
};
}
17 changes: 17 additions & 0 deletions test/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from "node:path";

import {
formatVersionOutput,
formatUnsupportedTuiProviderMessage,
isTuiRuntimeInvocation,
isVersionInvocation,
normalizeLoginArgs,
Expand All @@ -14,6 +15,7 @@ import {
resolveModelRetryMaxRetries,
withDefaultBrowserUse
} from "../src/launcher.ts";
import type { ConfiguredModelAccess } from "../src/model-access.ts";
import { classifyZaiOAuthInvocation } from "../src/zai-oauth.ts";

describe("launcher routing", () => {
Expand Down Expand Up @@ -72,6 +74,21 @@ describe("launcher routing", () => {
});
});

test("explains when configured headless access cannot pass the upstream TUI gate", () => {
const access: ConfiguredModelAccess = {
configPath: "/home/user/.zcode/cli/config.json",
model: "custom/GLM-5.3",
providerId: "custom",
tuiCompatible: false
};

expect(formatUnsupportedTuiProviderMessage(access)).toContain(
'provider ID "zai" or "bigmodel"'
);
expect(formatUnsupportedTuiProviderMessage(access)).toContain(access.configPath);
expect(formatUnsupportedTuiProviderMessage(access)).not.toContain("configured-key");
});

test("enables Browser Use only for agent-producing runtime invocations", () => {
expect(withDefaultBrowserUse([])).toEqual(["--browser-use=headless"]);
expect(withDefaultBrowserUse(["tui"])).toEqual(["--browser-use=headless", "tui"]);
Expand Down
26 changes: 25 additions & 1 deletion test/model-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,31 @@ describe("configured model access", () => {
expect(await readConfiguredModelAccess(env)).toEqual({
configPath: path,
model: "zai/custom/model",
providerId: "zai"
providerId: "zai",
tuiCompatible: true
});
});

test("reports custom provider ids as headless-only for the upstream TUI gate", async () => {
const home = await temporaryHome();
const env = { HOME: home, USERPROFILE: home };
const path = userConfigPath(env);
await mkdir(join(home, ".zcode", "cli"), { recursive: true });
await writeFile(path, JSON.stringify({
provider: {
"bigmodel-coding-plan": {
options: { apiKey: "configured-key" },
models: { "GLM-5.3": { name: "GLM-5.3" } }
}
},
model: { main: "bigmodel-coding-plan/GLM-5.3" }
}));

expect(await readConfiguredModelAccess(env)).toEqual({
configPath: path,
model: "bigmodel-coding-plan/GLM-5.3",
providerId: "bigmodel-coding-plan",
tuiCompatible: false
});
});

Expand Down