From 7df0569a39355741feaf6d57b311c23ddf8380fb Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 13 Feb 2026 16:23:49 -0800 Subject: [PATCH] feat(models): add Z.AI (GLM) credential integration and setup flow Implement first-class Z.AI credential storage and authentication: - New auth provider: src/auth/zai.ts for Z.AI API key management - New CLI command: flynn zai-auth to store Z.AI API keys - New TUI command: /login zai for interactive credential entry - Modified src/auth/index.ts to register zai provider - Modified src/cli/index.ts to register zai-auth command - Modified src/cli/setup/providers.ts to include Z.AI in setup wizard - Modified src/daemon/models.ts to support zhipuai use_oauth flag - Modified src/daemon/clientFactory.test.ts to add Z.AI tests - Modified src/frontends/tui/commands.ts to add login command - Modified src/frontends/tui/minimal.ts to support credential prompts This allows users to authenticate with Z.AI (GLM models) without embedding secrets in config files. Credentials are stored securely in ~/.config/flynn/auth.json and resolved at runtime. Updated state.json with new feature entry documenting the integration. --- docs/plans/state.json | 20 ++++++++ src/auth/index.ts | 8 +++ src/auth/zai.ts | 84 ++++++++++++++++++++++++++++++++ src/cli/index.ts | 2 + src/cli/setup/providers.ts | 2 + src/cli/zai-auth.ts | 59 ++++++++++++++++++++++ src/daemon/clientFactory.test.ts | 20 ++++++++ src/daemon/models.ts | 16 ++++++ src/frontends/tui/commands.ts | 4 +- src/frontends/tui/minimal.ts | 60 ++++++++++++++++++++++- 10 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 src/auth/zai.ts create mode 100644 src/cli/zai-auth.ts diff --git a/docs/plans/state.json b/docs/plans/state.json index ae62060..2dc39ef 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -777,6 +777,26 @@ ], "test_status": "pnpm typecheck + pnpm test:run passing" }, + + "zai-glm-4.7-credential-integration": { + "status": "completed", + "date": "2026-02-13", + "summary": "Added first-class Z.AI (GLM) credential storage and setup flow. Z.AI authenticates via API keys (HTTP Bearer), so Flynn now supports `flynn zai-auth` to store the key in ~/.config/flynn/auth.json, `/login zai` in the minimal TUI, and `use_oauth: true` for the zhipuai provider to resolve credentials from the auth store (or env) without embedding secrets in config.", + "files_created": [ + "src/auth/zai.ts", + "src/cli/zai-auth.ts" + ], + "files_modified": [ + "src/auth/index.ts", + "src/cli/index.ts", + "src/cli/setup/providers.ts", + "src/daemon/models.ts", + "src/daemon/clientFactory.test.ts", + "src/frontends/tui/minimal.ts", + "src/frontends/tui/commands.ts" + ], + "test_status": "pnpm build + pnpm typecheck + pnpm test:run passing" + }, "runtime-context-awareness": { "status": "completed", "date": "2026-02-07", diff --git a/src/auth/index.ts b/src/auth/index.ts index e9d3b75..4e648b6 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -20,3 +20,11 @@ export { type OpenAIOAuthInfo, type IdTokenClaims, } from './openai.js'; + +export { + loadStoredZaiAuth, + storeZaiAuth, + clearZaiAuth, + getZaiApiKey, + type ZaiAuthInfo, +} from './zai.js'; diff --git a/src/auth/zai.ts b/src/auth/zai.ts new file mode 100644 index 0000000..2641051 --- /dev/null +++ b/src/auth/zai.ts @@ -0,0 +1,84 @@ +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +const AUTH_DIR = resolve(homedir(), '.config/flynn'); +const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); + +export interface ZaiAuthInfo { + /** Z.AI API key (used as Bearer token). */ + api_key: string; + created_at: string; +} + +interface AuthStore { + // Keep other provider entries untyped to avoid cross-module coupling. + github?: unknown; + openai?: unknown; + /** Preferred key for Z.AI credentials. */ + zai?: ZaiAuthInfo; + /** Back-compat: older naming that matches the model provider id. */ + zhipuai?: ZaiAuthInfo; +} + +function safeJsonParse(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function readAuthStore(): AuthStore { + try { + const raw = readFileSync(AUTH_FILE, 'utf-8'); + const parsed = safeJsonParse(raw); + return parsed ?? {}; + } catch { + return {}; + } +} + +function writeAuthStore(store: AuthStore): void { + mkdirSync(AUTH_DIR, { recursive: true }); + writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8'); + chmodSync(AUTH_FILE, 0o600); +} + +export function loadStoredZaiAuth(): ZaiAuthInfo | null { + const store = readAuthStore(); + return store.zai ?? store.zhipuai ?? null; +} + +export function storeZaiAuth(apiKey: string): void { + const trimmed = apiKey.trim(); + if (!trimmed) { + throw new Error('Z.AI API key is empty'); + } + const store = readAuthStore(); + store.zai = { api_key: trimmed, created_at: new Date().toISOString() }; + // Also write the provider-id alias for compatibility. + store.zhipuai = store.zai; + writeAuthStore(store); +} + +export function clearZaiAuth(): void { + const store = readAuthStore(); + delete store.zai; + delete store.zhipuai; + writeAuthStore(store); +} + +/** + * Get a Z.AI credential from any available source. + * Priority: ZAI_API_KEY → ZHIPUAI_API_KEY → ZHIPUAI_AUTH_TOKEN → stored auth.json. + */ +export function getZaiApiKey(): string | null { + const raw = process.env.ZAI_API_KEY + ?? process.env.ZHIPUAI_API_KEY + ?? process.env.ZHIPUAI_AUTH_TOKEN + ?? loadStoredZaiAuth()?.api_key; + + if (!raw) {return null;} + return raw.startsWith('Bearer ') ? raw.slice('Bearer '.length) : raw; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e677269..21632e2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,6 +19,7 @@ import { registerGdocsAuthCommand } from './gdocs-auth.js'; import { registerGdriveAuthCommand } from './gdrive-auth.js'; import { registerGtasksAuthCommand } from './gtasks-auth.js'; import { registerOpenaiAuthCommand } from './openai-auth.js'; +import { registerZaiAuthCommand } from './zai-auth.js'; import { registerSkillsCommand } from './skills.js'; export function createProgram(): Command { @@ -43,6 +44,7 @@ export function createProgram(): Command { registerGdriveAuthCommand(program); registerGtasksAuthCommand(program); registerOpenaiAuthCommand(program); + registerZaiAuthCommand(program); registerSkillsCommand(program); return program; diff --git a/src/cli/setup/providers.ts b/src/cli/setup/providers.ts index 3869abe..bc56181 100644 --- a/src/cli/setup/providers.ts +++ b/src/cli/setup/providers.ts @@ -21,6 +21,7 @@ const TOP_TIER: ProviderDef[] = [ const SECOND_TIER: ProviderDef[] = [ { name: 'Gemini', provider: 'gemini', defaultModel: 'gemini-2.5-flash', fastModel: 'gemini-2.0-flash-lite', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'Gemini API key' }, { name: 'OpenRouter', provider: 'openrouter', defaultModel: 'anthropic/claude-sonnet-4', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'OpenRouter API key' }, + { name: 'Z.AI (GLM)', provider: 'zhipuai', defaultModel: 'glm-4.7', needsApiKey: true, needsEndpoint: true, defaultEndpoint: 'https://api.z.ai/api/paas/v4', apiKeyLabel: 'Z.AI API key' }, { name: 'xAI (Grok)', provider: 'xai', defaultModel: 'grok-3', fastModel: 'grok-3-mini', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'xAI API key' }, { name: 'Amazon Bedrock', provider: 'bedrock', defaultModel: 'anthropic.claude-sonnet-4-20250514-v1:0', needsApiKey: false, needsEndpoint: false }, { name: 'GitHub Models', provider: 'github', defaultModel: 'claude-sonnet-4-20250514', needsApiKey: false, needsEndpoint: false }, @@ -32,6 +33,7 @@ const PROVIDER_HELP: Record = { ollama: 'Ollama runs locally — install from https://ollama.com and run: ollama serve', gemini: 'Get your API key at https://aistudio.google.com/apikey', openrouter: 'Get your API key at https://openrouter.ai/keys (supports 200+ models)', + zhipuai: 'Get your API key at https://z.ai/manage-apikey/apikey-list (Coding Plan endpoint: https://api.z.ai/api/coding/paas/v4)', xai: 'Get your API key at https://console.x.ai', bedrock: 'Uses AWS credentials from environment (~/.aws/credentials or IAM role)', github: 'Uses GitHub Copilot — authenticate via OAuth device flow on first use', diff --git a/src/cli/zai-auth.ts b/src/cli/zai-auth.ts new file mode 100644 index 0000000..a3f444b --- /dev/null +++ b/src/cli/zai-auth.ts @@ -0,0 +1,59 @@ +import type { Command } from 'commander'; +import readline from 'readline'; +import { loadStoredZaiAuth, storeZaiAuth } from '../auth/index.js'; + +async function promptHidden(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const rlAny = rl as unknown as { stdoutMuted?: boolean; _writeToOutput?: (s: string) => void }; + rlAny.stdoutMuted = true; + + rlAny._writeToOutput = (s: string) => { + if (!rlAny.stdoutMuted) { + process.stdout.write(s); + return; + } + // Mask input characters, but preserve newlines. + if (s.includes('\n')) { + process.stdout.write('\n'); + } else { + process.stdout.write('*'); + } + }; + + const answer = await new Promise((resolve) => rl.question(question, resolve)); + rlAny.stdoutMuted = false; + rl.close(); + process.stdout.write('\n'); + return answer.trim(); +} + +export function registerZaiAuthCommand(program: Command): void { + program + .command('zai-auth') + .description('Store a Z.AI API key for the zhipuai provider (auth.json)') + .action(async () => { + const existing = loadStoredZaiAuth(); + if (existing) { + console.log('Z.AI credential already exists.'); + console.log('Delete ~/.config/flynn/auth.json zai/zhipuai entry if you want to re-authenticate.'); + process.exit(0); + } + + console.log('Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.'); + console.log('Create a key at: https://z.ai/manage-apikey/apikey-list'); + console.log(''); + + try { + const apiKey = await promptHidden('Enter Z.AI API key: '); + storeZaiAuth(apiKey); + console.log(''); + console.log('Z.AI credential stored in ~/.config/flynn/auth.json'); + console.log(''); + console.log('Tip: For GLM Coding Plan set model endpoint to: https://api.z.ai/api/coding/paas/v4'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Z.AI auth failed: ${message}`); + process.exit(1); + } + }); +} diff --git a/src/daemon/clientFactory.test.ts b/src/daemon/clientFactory.test.ts index 8583e57..2824e07 100644 --- a/src/daemon/clientFactory.test.ts +++ b/src/daemon/clientFactory.test.ts @@ -105,6 +105,26 @@ describe('createClientFromConfig', () => { expect(client).toBeInstanceOf(OpenAIClient); }); + it('creates OpenAIClient for zhipuai when use_oauth is enabled and ZAI_API_KEY is set', () => { + const prev = process.env.ZAI_API_KEY; + process.env.ZAI_API_KEY = 'zai-api-key'; + + try { + const client = createClientFromConfig({ + provider: 'zhipuai', + model: 'glm-4.7', + use_oauth: true, + }); + expect(client).toBeInstanceOf(OpenAIClient); + } finally { + if (prev === undefined) { + delete process.env.ZAI_API_KEY; + } else { + process.env.ZAI_API_KEY = prev; + } + } + }); + it('creates OpenAIClient for zhipuai using ZHIPUAI_AUTH_TOKEN env var', () => { const prev = process.env.ZHIPUAI_AUTH_TOKEN; process.env.ZHIPUAI_AUTH_TOKEN = 'oauth-access-token'; diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 475c746..7d4b77b 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -2,6 +2,7 @@ import type { Config, ModelConfig } from '../config/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js'; import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import { logger } from '../logger.js'; +import { getZaiApiKey } from '../auth/zai.js'; /** * Resolve an API key from config or environment variable. @@ -78,6 +79,21 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1', }); case 'zhipuai': + if (cfg.use_oauth) { + const apiKey = getZaiApiKey(); + if (!apiKey) { + throw new Error( + 'Z.AI credential not configured. ' + + 'Run `flynn zai-auth` or set ZAI_API_KEY / ZHIPUAI_API_KEY / ZHIPUAI_AUTH_TOKEN.', + ); + } + return new OpenAIClient({ + model: cfg.model, + apiKey, + baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', + }); + } + return new OpenAIClient({ model: cfg.model, apiKey: resolveAuthCredential(cfg, 'ZHIPUAI_API_KEY', 'ZHIPUAI_AUTH_TOKEN'), diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index 9602ee0..e05f856 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -124,7 +124,7 @@ Commands: /model [name] Show or switch model tier (local, default, fast, complex) /model

Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4) /backend [provider] Show or switch local backend (ollama, llamacpp) - /login [provider] Authenticate with GitHub or OpenAI + /login [provider] Authenticate with GitHub, OpenAI, or Z.AI /pair List pending pairing codes and approved senders /pair generate [label] Generate a new DM pairing code /pair revoke Revoke an approved sender @@ -178,7 +178,7 @@ export const COMMAND_TOOLTIPS: Record = { '/status': 'Show session info and token usage', '/fullscreen': 'Switch to fullscreen mode', '/fs': 'Switch to fullscreen mode', - '/login': 'Authenticate with GitHub or OpenAI (OAuth device flow)', + '/login': 'Authenticate with GitHub/OpenAI (OAuth) or Z.AI (API key store)', '/pair': 'Generate/list/revoke DM pairing codes', '/transfer': 'Transfer session to another frontend', '/quit': 'Exit TUI', diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 88afe03..5e65939 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -9,7 +9,7 @@ import type { ModelConfig, ModelProvider } from '../../config/schema.js'; import { MODEL_PROVIDERS } from '../../config/schema.js'; import { OllamaClient, LlamaCppClient } from '../../models/index.js'; import { createClientFromConfig } from '../../daemon/index.js'; -import { loginGitHub, loginOpenAI } from '../../auth/index.js'; +import { loginGitHub, loginOpenAI, loadStoredZaiAuth, storeZaiAuth } from '../../auth/index.js'; import type { PairingManager } from '../../channels/pairing.js'; import { getColoredBanner } from './banner.js'; import type { HookEngine } from '../../hooks/index.js'; @@ -465,7 +465,63 @@ export class MinimalTui { return; } - console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai\n`); + if (target === 'zai' || target === 'zhipuai') { + const existing = loadStoredZaiAuth(); + if (existing) { + console.log(`${colors.gray}Z.AI credential already exists.${colors.reset}`); + console.log(`${colors.gray}Delete ~/.config/flynn/auth.json zai/zhipuai entry to re-authenticate.${colors.reset}\n`); + return; + } + + console.log(`${colors.gray}Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.${colors.reset}`); + console.log(`${colors.gray}Create a key at:${colors.reset} https://z.ai/manage-apikey/apikey-list`); + console.log(''); + + if (!this.rl) { + console.log(`${colors.gray}TUI not ready for login prompt. Run: flynn zai-auth${colors.reset}\n`); + return; + } + + const promptHidden = async (question: string): Promise => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const rlAny = rl as any; + rlAny.stdoutMuted = true; + rlAny._writeToOutput = (s: string) => { + if (!rlAny.stdoutMuted) { + process.stdout.write(s); + return; + } + if (s.includes('\n')) { + process.stdout.write('\n'); + } else { + process.stdout.write('*'); + } + }; + const answer = await new Promise((resolve) => rl.question(question, resolve)); + rlAny.stdoutMuted = false; + rl.close(); + process.stdout.write('\n'); + return answer.trim(); + }; + + try { + this.rl.pause(); + const apiKey = await promptHidden('Enter Z.AI API key: '); + storeZaiAuth(apiKey); + console.log(''); + console.log(`${colors.gray}Z.AI credential stored in ~/.config/flynn/auth.json${colors.reset}`); + console.log(`${colors.gray}Tip: For GLM Coding Plan set endpoint to https://api.z.ai/api/coding/paas/v4${colors.reset}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}Z.AI auth failed:${colors.reset} ${message}\n`); + } finally { + this.rl.resume(); + } + + return; + } + + console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai, zai\n`); } private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void {