From 2613daef49fadec8410a90a1dfe6ac3f0aa3627d Mon Sep 17 00:00:00 2001 From: yaraul Date: Mon, 6 Jul 2026 19:51:18 +0300 Subject: [PATCH] fix(llm): support versioned OpenAI-compatible endpoints + optional bearer for local provider Two issues blocked OpenAI-compatible servers that aren't Ollama-on-/v1: 1. LocalAdapter.isOpenAIWire matched only a literal /v1 path, so versioned endpoints like Zhipu/z.ai's /api/paas/v4 silently fell through to Ollama's native /api/chat wire format and failed. Match any /vN instead of literally /v1. 2. The `local` provider was treated as always-keyless, so servers that require a bearer (Zhipu/z.ai, Together, ...) could never authenticate. Read an optional key from TEMPEST_LOCAL_API_KEY (ZAI_API_KEY / ZHIPUAI_API_KEY as aliases), wire it through getApiKey/hasApiKey so `tempest test` and the mission engine accept a configured local provider, and document the options in the env template. The key is read from env in-memory and never persisted into the store, matching the existing env-key hardening invariant (api-key-env-static). Adds src/__tests__/local-openai-wire-static.test.ts pinning both fixes. Verified end-to-end against z.ai's GLM models (glm-4.5-air / 4.6 / 5.2). --- .../local-openai-wire-static.test.ts | 66 +++++++++++++++++++ src/config/index.ts | 36 ++++++++-- src/llm/index.ts | 7 +- 3 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/local-openai-wire-static.test.ts diff --git a/src/__tests__/local-openai-wire-static.test.ts b/src/__tests__/local-openai-wire-static.test.ts new file mode 100644 index 0000000..ce03bc6 --- /dev/null +++ b/src/__tests__/local-openai-wire-static.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// Static invariants for the `local` provider. Two real OpenAI-compatible servers +// broke against the old code: (1) the wire detector only matched a literal /v1, so +// versioned paths like Zhipu/z.ai's /api/paas/v4 silently fell through to the +// Ollama native format and failed; (2) the provider was treated as always-keyless, +// so servers that require a bearer (Zhipu, Together, …) could never authenticate. +// These tests pin the fixes in place and document the intent. + +const llmSource = readFileSync(join(process.cwd(), 'src/llm/index.ts'), 'utf8'); +const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8'); + +function block(source: string, startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + expect(start, `missing marker "${startMarker}"`).toBeGreaterThanOrEqual(0); + const end = source.indexOf(endMarker, start); + expect(end, `missing end marker "${endMarker}"`).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe('local OpenAI-compatible provider hardening', () => { + it('isOpenAIWire detects ANY versioned path (/v1, /v4, …), not only /v1', () => { + const fn = block( + llmSource, + 'private isOpenAIWire(baseUrl: string): boolean {', + '\n }', + ); + + // Must be version-agnostic: a digit class, not a literal `1`. + expect(fn).toMatch(/v\\d\+/); + // No literal-/v1-only form must remain. + expect(fn).not.toMatch(/\/v1\(/); + }); + + it('getLLMConfig forwards an optional bearer for local servers that require auth', () => { + const localCase = block(configSource, "case 'local':", 'break;'); + + expect(localCase).toContain('TEMPEST_LOCAL_API_KEY'); + // Provider-specific aliases are accepted as a convenience. + expect(localCase).toMatch(/ZAI_API_KEY/); + expect(localCase).toMatch(/ZHIPUAI_API_KEY/); + }); + + it('getApiKey resolves a local key from env without persisting it', () => { + // The local key is read in-memory from env, matching the env-key hardening + // invariant (env keys are never written to the store). + const getApiKey = block(configSource, 'getApiKey(provider:', 'envVarMap'); + + expect(getApiKey).toContain("provider === 'local'"); + expect(getApiKey).toContain('TEMPEST_LOCAL_API_KEY'); + }); + + it('the local key slot is part of the typed apiKeys store', () => { + expect(configSource).toMatch(/local\?:\s*string;/); + }); + + it('the env template documents the local provider options', () => { + const template = block(configSource, 'createEnvTemplate(', '`;'); + + expect(template).toContain('TEMPEST_LOCAL_BASE_URL'); + expect(template).toContain('TEMPEST_LOCAL_MODEL'); + expect(template).toContain('TEMPEST_LOCAL_API_KEY'); + }); +}); diff --git a/src/config/index.ts b/src/config/index.ts index fa2ea75..e4fefc5 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -23,6 +23,7 @@ export interface TempestSettings { anthropic?: string; openai?: string; xai?: string; + local?: string; }; // Default LLM settings @@ -547,7 +548,7 @@ class ConfigManager { /** * Set an API key for a provider */ - setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai', key: string): void { + setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local', key: string): void { const apiKeys = this.config.get('apiKeys'); apiKeys[provider] = key; this.config.set('apiKeys', apiKeys); @@ -556,8 +557,15 @@ class ConfigManager { /** * Get an API key for a provider */ - getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai'): string | undefined { + getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local'): string | undefined { // First check environment variables (highest priority) + // local provider: a self-hosted/OpenAI-compatible server MAY require a bearer + // (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars. + if (provider === 'local') { + const localKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim(); + if (localKey) return localKey; + return this.config.get('apiKeys')[provider]; + } const envVarMap = { openrouter: 'OPENROUTER_API_KEY', venice: 'VENICE_API_KEY', @@ -582,7 +590,7 @@ class ConfigManager { /** * Check if a provider has a valid API key configured */ - hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai'): boolean { + hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local'): boolean { const key = this.getApiKey(provider); return !!key && key.length > 10; } @@ -590,7 +598,7 @@ class ConfigManager { /** * Remove an API key */ - removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai'): void { + removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local'): void { const apiKeys = this.config.get('apiKeys'); delete apiKeys[provider]; this.config.set('apiKeys', apiKeys); @@ -661,13 +669,16 @@ class ConfigManager { actualModel = 'mock-model'; break; case 'local': - // Self-hosted, keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at - // any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp) - // and TEMPEST_LOCAL_MODEL at the model tag you're serving. + // Self-hosted, usually keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at + // any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp, Zhipu/z.ai + // /api/paas/v4) and TEMPEST_LOCAL_MODEL at the model tag you're serving. + // Some OpenAI-compatible servers require a real bearer (Zhipu, Together, etc.) — + // TEMPEST_LOCAL_API_KEY (or a provider-specific env like ZAI_API_KEY) provides it. baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api'; // 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so // TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority. actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3'; + apiKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim(); break; default: throw new Error(`Unknown provider: ${actualProvider}`); @@ -829,6 +840,17 @@ OPENAI_API_KEY= # xAI API Key # Get your key at: https://console.x.ai/ XAI_API_KEY= + +# Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server) +# Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below). +# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1, +# vLLM :8000/v1, or e.g. Zhipu/z.ai /api/paas/v4 — any /vN path speaks the +# OpenAI wire (/chat/completions, choices[]); otherwise the Ollama native format is used. +# Most local servers are keyless; if yours requires a bearer (Zhipu, Together, …) +# set TEMPEST_LOCAL_API_KEY (ZAI_API_KEY / ZHIPUAI_API_KEY are accepted as aliases). +TEMPEST_LOCAL_BASE_URL=http://localhost:11434/api +TEMPEST_LOCAL_MODEL=llama3 +TEMPEST_LOCAL_API_KEY= `; writeFileSync(filePath, template); } diff --git a/src/llm/index.ts b/src/llm/index.ts index 3e781fc..d796e73 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -725,9 +725,12 @@ class LocalAdapter implements LLMProviderAdapter { return { valid: true }; } - // A /v1 base URL means an OpenAI-compatible server (/chat/completions, choices[]). + // A versioned base URL (/v1, /v2, /v4, …) means an OpenAI-compatible server + // (/chat/completions, choices[]). Many OpenAI-compatible providers version their + // API at paths other than /v1 (e.g. Zhipu/z.ai exposes /api/paas/v4), so match any + // /vN rather than literally /v1. private isOpenAIWire(baseUrl: string): boolean { - return /\/v1(\/|$)/.test(baseUrl); + return /\/v\d+(\/|$)/.test(baseUrl); } // Inject the Arsenal contract as a system turn when tools are offered, and