From dd15ccb927b896ce0e6b997b02a202cf804bf3d8 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 15 Feb 2026 19:47:27 -0800 Subject: [PATCH] Fix Z.AI credential resolution and improve 401 auth diagnostics --- docs/plans/state.json | 14 ++++++++++++++ src/daemon/clientFactory.test.ts | 20 ++++++++++++++++++++ src/daemon/models.ts | 28 ++++++---------------------- src/models/openai.test.ts | 17 +++++++++++++++++ src/models/openai.ts | 27 ++++++++++++++++++++++++++- 5 files changed, 83 insertions(+), 23 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 5a2bb2e..b0adcc2 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -43,6 +43,20 @@ "test_status": "pnpm test:run + pnpm typecheck passing" }, + "zai-auth-resolution-and-401-hints": { + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Unified Z.AI credential resolution so zhipuai model switches resolve credentials from config, env (including ZAI_API_KEY), and auth store regardless of use_oauth flag. Added clearer Z.AI-specific 401 diagnostics when API auth fails (including missing model.request scope hint).", + "files_modified": [ + "src/daemon/models.ts", + "src/models/openai.ts", + "src/daemon/clientFactory.test.ts", + "src/models/openai.test.ts" + ], + "test_status": "pnpm test:run src/daemon/clientFactory.test.ts src/models/openai.test.ts + pnpm typecheck passing" + }, + "deployment-port-env-override": { "status": "completed", "date": "2026-02-16", diff --git a/src/daemon/clientFactory.test.ts b/src/daemon/clientFactory.test.ts index 188ceac..fbf483d 100644 --- a/src/daemon/clientFactory.test.ts +++ b/src/daemon/clientFactory.test.ts @@ -185,6 +185,26 @@ describe('createClientFromConfig', () => { } }); + it('creates OpenAIClient for zhipuai using ZAI_API_KEY env var without use_oauth', async () => { + const prev = process.env.ZAI_API_KEY; + process.env.ZAI_API_KEY = 'zai-api-key'; + + try { + const { createClientFromConfig } = await loadFactory(); + const client = createClientFromConfig({ + provider: 'zhipuai', + model: 'glm-4.7', + }); + expect(client.constructor.name).toBe('OpenAIClient'); + } finally { + if (prev === undefined) { + delete process.env.ZAI_API_KEY; + } else { + process.env.ZAI_API_KEY = prev; + } + } + }); + it('creates OpenAIClient for minimax provider', async () => { const prev = process.env.MINIMAX_API_KEY; process.env.MINIMAX_API_KEY = 'test-key'; diff --git a/src/daemon/models.ts b/src/daemon/models.ts index fd1fc88..ac9dd66 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -33,17 +33,16 @@ function requireApiKey(cfg: ModelConfig, envVar: string): string { return key; } -function resolveAuthCredential(cfg: ModelConfig, apiKeyEnvVar: string, authTokenEnvVar?: string): string { +function resolveZaiCredential(cfg: ModelConfig): string { const raw = cfg.api_key ?? cfg.auth_token - ?? process.env[apiKeyEnvVar] - ?? (authTokenEnvVar ? process.env[authTokenEnvVar] : undefined); + ?? getZaiApiKey(); if (!raw) { - const envHint = authTokenEnvVar ? `${apiKeyEnvVar} or ${authTokenEnvVar}` : apiKeyEnvVar; throw new Error( - `Credential required for ${cfg.provider}. ` + - `Set ${envHint} environment variable or provide api_key/auth_token in config.`, + 'Z.AI credential not configured. ' + + 'Run `flynn zai-auth` or set ZAI_API_KEY / ZHIPUAI_API_KEY / ZHIPUAI_AUTH_TOKEN, ' + + 'or provide api_key/auth_token in config.', ); } @@ -197,24 +196,9 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { baseURL: cfg.endpoint ?? 'https://ai-gateway.vercel.sh/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'), + apiKey: resolveZaiCredential(cfg), baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', }); case 'xai': diff --git a/src/models/openai.test.ts b/src/models/openai.test.ts index a5ca8cd..c04a9c3 100644 --- a/src/models/openai.test.ts +++ b/src/models/openai.test.ts @@ -135,4 +135,21 @@ describe('OpenAIClient tool use', () => { expect(response.stopReason).toBe('max_tokens'); }); + + it('rewrites Z.AI 401 errors with actionable auth guidance', async () => { + mockCreate.mockRejectedValueOnce({ + status: 401, + message: '401 You have insufficient permissions for this operation. Missing scopes: model.request.', + }); + + const client = new OpenAIClient({ + apiKey: 'zai-key', + model: 'glm-4.7', + baseURL: 'https://api.z.ai/api/paas/v4', + }); + + await expect(client.chat({ + messages: [{ role: 'user', content: 'hello' }], + })).rejects.toThrow(/Z\.AI authentication failed \(401\)/); + }); }); diff --git a/src/models/openai.ts b/src/models/openai.ts index f779490..e4a33ac 100644 --- a/src/models/openai.ts +++ b/src/models/openai.ts @@ -61,10 +61,12 @@ export class OpenAIClient implements ModelClient { private model: string; private defaultMaxTokens: number; private useOAuth: boolean; + private baseURL?: string; constructor(config: OpenAIClientConfig) { const timeoutMs = config.timeoutMs ?? 20_000; this.useOAuth = Boolean(config.useOAuth); + this.baseURL = config.baseURL; // OAuth mode uses a different backend (ChatGPT Codex) and a different API shape. // Only initialize the OpenAI SDK for API-key providers. @@ -248,7 +250,30 @@ export class OpenAIClient implements ModelClient { (params as any).reasoning_effort = 'medium'; } - const response = await this.client.chat.completions.create(params); + let response: OpenAI.ChatCompletion; + try { + response = await this.client.chat.completions.create(params); + } catch (error) { + const status = typeof (error as { status?: unknown })?.status === 'number' + ? (error as { status: number }).status + : undefined; + const message = error instanceof Error ? error.message : String(error); + + const isZai = (this.baseURL ?? '').includes('api.z.ai'); + const missingModelRequestScope = message.includes('Missing scopes: model.request'); + + if (isZai && status === 401) { + const hint = missingModelRequestScope + ? 'The key lacks `model.request` scope.' + : 'The API key is invalid, expired, or not allowed for this model/endpoint.'; + throw new Error( + `Z.AI authentication failed (401). ${hint} ` + + 'Run `flynn zai-auth` to update credentials, or set ZAI_API_KEY / ZHIPUAI_API_KEY / ZHIPUAI_AUTH_TOKEN.', + ); + } + + throw error; + } const choice = response.choices[0]; const content = choice?.message?.content ?? '';