From 79e43aabe7cc5305db6f1ca1a8d31c4d5785e793 Mon Sep 17 00:00:00 2001 From: scr1p7k177y Date: Thu, 11 Jun 2026 09:19:49 +0800 Subject: [PATCH] Reach any OpenAI-compatible endpoint via base_url (v0.14.2) dispatchOpenAI now uses plain fetch with a configurable base_url (OPENAI_BASE_URL or providers.openai.base_url), dropping the OpenAI SDK dependency, and reads reasoning_content when content is empty so reasoning models (qwen3, deepseek-r1) aren't silently lost. --- CHANGELOG.md | 17 ++++++++ README.md | 21 ++++++++- config/providers.yaml | 7 +++ lib/llm-adapter.js | 42 ++++++++++++------ package-lock.json | 4 +- package.json | 2 +- server.js | 2 +- test/unit/llm-adapter.test.js | 80 +++++++++++++++++++++++++++++++++++ 8 files changed, 157 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a3fa7..9701316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.14.2] - 2026-06-11 +### Changed +- `openai` provider now reaches any OpenAI-compatible `/chat/completions` endpoint + (NVIDIA, Together, Groq, OpenRouter, vLLM, LM Studio, …) via `base_url` in + `config/providers.yaml` or the `OPENAI_BASE_URL` env var. Dispatch uses plain + `fetch` instead of the OpenAI SDK, so there is no extra dependency to install. +- Responses from reasoning models (qwen3, deepseek-r1, …) that leave `content` + empty and put the answer in `reasoning_content` are now read correctly instead + of being silently dropped. + +### Added +- Commented NVIDIA / OpenAI-compatible example in `config/providers.yaml` and a + README section documenting `base_url` overrides. +- Unit tests for the OpenAI-compatible path (URL + auth, `content` / + `reasoning_content` parsing, trailing-slash base_url, non-ok status, usage + estimation fallback). + ## [0.14.1] - 2026-06-10 ### Added - `examples/demo.mjs`, an offline end-to-end walkthrough. It spawns the real diff --git a/README.md b/README.md index da589e8..8547180 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CEAuto -![version](https://img.shields.io/badge/version-0.14.1-blue) +![version](https://img.shields.io/badge/version-0.14.2-blue) ![node](https://img.shields.io/badge/node-18%2B-339933?logo=node.js&logoColor=white) ![MCP](https://img.shields.io/badge/MCP-compatible-blueviolet) ![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) @@ -473,6 +473,23 @@ providers: api_key_env: OPENAI_API_KEY ``` +### OpenAI-compatible providers + +The `openai` provider talks plain `/chat/completions`, so any service that speaks +that REST shape works — NVIDIA, Together, Groq, OpenRouter, vLLM, LM Studio, and +friends. Point `base_url` at the endpoint, set the model id, and supply the key in +`OPENAI_API_KEY` (or `OPENAI_BASE_URL` to override the URL without editing config): + +```yaml +providers: + openai: + base_url: https://integrate.api.nvidia.com/v1 + default_model: meta/llama-3.3-70b-instruct +``` + +Reasoning models (qwen3, deepseek-r1, …) that return the answer in +`reasoning_content` instead of `content` are handled — the adapter reads either. + Fill in `memory/context.md` with your company/project context and `strategy/goals.md` with current goals before calling `ceo_boot`. ## What's Where @@ -494,7 +511,7 @@ CEAuto/ ├── memory/context.md # your company/project context (fill this in) ├── strategy/goals.md # current strategic goals (fill this in) ├── workflows/ # YAML workflow definitions -├── test/ # unit + integration + e2e (199 tests, mock provider) +├── test/ # unit + integration + e2e (204 tests, mock provider) ├── install.sh # installer (Linux/macOS/Git Bash) └── install.bat # installer (Windows) ``` diff --git a/config/providers.yaml b/config/providers.yaml index c17c00e..f6be138 100644 --- a/config/providers.yaml +++ b/config/providers.yaml @@ -13,6 +13,13 @@ providers: openai: default_model: gpt-4o # api_key: sk-... # Set OPENAI_API_KEY env var instead + # base_url: https://api.openai.com/v1 # default; override for any OpenAI-compatible endpoint + # Any service speaking the OpenAI /chat/completions REST shape works here — + # NVIDIA, Together, Groq, OpenRouter, vLLM, LM Studio, … Point base_url at it, + # set default_model to that service's model id, and put the key in OPENAI_API_KEY + # (or OPENAI_BASE_URL env). Example (NVIDIA): + # base_url: https://integrate.api.nvidia.com/v1 + # default_model: meta/llama-3.3-70b-instruct google: default_model: gemini-1.5-pro diff --git a/lib/llm-adapter.js b/lib/llm-adapter.js index e90cb07..5bf3454 100644 --- a/lib/llm-adapter.js +++ b/lib/llm-adapter.js @@ -82,27 +82,45 @@ async function dispatchAnthropic(model, systemPrompt, task, context) { }; } +// OpenAI and any OpenAI-compatible endpoint (NVIDIA, Together, Groq, OpenRouter, +// vLLM, LM Studio, …) over the /chat/completions REST shape. base_url comes from +// OPENAI_BASE_URL or config (providers.openai.base_url), defaulting to OpenAI. +// Plain fetch, so no SDK dependency. async function dispatchOpenAI(model, systemPrompt, task, context) { const apiKey = getApiKey('openai'); if (!apiKey) throw new Error('OPENAI_API_KEY not set'); + const cfg = loadConfig(); + // env wins over config, mirroring getApiKey(), so OPENAI_BASE_URL can redirect + // traffic without editing providers.yaml. + const baseUrl = (process.env.OPENAI_BASE_URL || cfg.providers?.openai?.base_url || 'https://api.openai.com/v1') + .replace(/\/+$/, ''); - const { OpenAI } = require('openai'); - const client = new OpenAI({ apiKey }); - - const response = await client.chat.completions.create({ - model, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: buildPrompt(task, context) }, - ], + const res = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: buildPrompt(task, context) }, + ], + }), }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`OpenAI-compatible API ${res.status}: ${detail.slice(0, 300)}`); + } - const text = response.choices[0].message.content; + const data = await res.json(); + const msg = data.choices?.[0]?.message || {}; + // Reasoning models (qwen3, deepseek-r1, …) leave `content` empty and put the + // answer in `reasoning_content`; fall back so the result isn't silently lost. + const text = msg.content || msg.reasoning_content || ''; return { text, usage: { - input_tokens: response.usage?.prompt_tokens ?? estimateTokens(`${systemPrompt}${context}${task}`), - output_tokens: response.usage?.completion_tokens ?? estimateTokens(text), + input_tokens: data.usage?.prompt_tokens ?? estimateTokens(`${systemPrompt}${context}${task}`), + output_tokens: data.usage?.completion_tokens ?? estimateTokens(text), }, }; } diff --git a/package-lock.json b/package-lock.json index 0f8510d..ab0794c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ceauto", - "version": "0.14.1", + "version": "0.14.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ceauto", - "version": "0.14.1", + "version": "0.14.2", "dependencies": { "@anthropic-ai/sdk": "^0.65.0", "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index e51c352..e9db957 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ceauto", - "version": "0.14.1", + "version": "0.14.2", "description": "Autonomous CEO Agent — LLM-agnostic MCP server with multi-agent orchestration", "main": "server.js", "type": "commonjs", diff --git a/server.js b/server.js index 1a93eb7..cdd595b 100644 --- a/server.js +++ b/server.js @@ -610,7 +610,7 @@ async function main() { memory.init(path.join(WORKSPACE, 'db', 'memory.sqlite')); const server = new Server( - { name: 'ceauto', version: '0.14.1' }, + { name: 'ceauto', version: '0.14.2' }, { capabilities: { tools: {} } } ); diff --git a/test/unit/llm-adapter.test.js b/test/unit/llm-adapter.test.js index 5ee5092..e19f415 100644 --- a/test/unit/llm-adapter.test.js +++ b/test/unit/llm-adapter.test.js @@ -35,3 +35,83 @@ describe('llm-adapter dispatch route override (Pillar 6)', () => { expect(usage.provider).toBe('mock'); }); }); + +describe('llm-adapter OpenAI-compatible dispatch', () => { + const route = { model: 'meta/llama-3.3-70b-instruct', provider: 'openai' }; + let prevMock, prevKey, prevBase, prevFetch; + + beforeEach(() => { + prevMock = process.env.CEAUTO_MOCK_LLM; + prevKey = process.env.OPENAI_API_KEY; + prevBase = process.env.OPENAI_BASE_URL; + prevFetch = global.fetch; + delete process.env.CEAUTO_MOCK_LLM; // hit the real provider switch, not the mock seam + delete process.env.OPENAI_BASE_URL; // no leak from a prior test; each test sets its own + process.env.OPENAI_API_KEY = 'test-key'; + }); + afterEach(() => { + if (prevMock === undefined) delete process.env.CEAUTO_MOCK_LLM; else process.env.CEAUTO_MOCK_LLM = prevMock; + if (prevKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = prevKey; + if (prevBase === undefined) delete process.env.OPENAI_BASE_URL; else process.env.OPENAI_BASE_URL = prevBase; + global.fetch = prevFetch; + }); + + function mockFetch(body, { ok = true, status = 200 } = {}) { + const fn = vi.fn(async () => ({ + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + })); + global.fetch = fn; + return fn; + } + + it('posts to {base_url}/chat/completions with bearer auth and parses content', async () => { + process.env.OPENAI_BASE_URL = 'https://integrate.api.nvidia.com/v1'; + const fn = mockFetch({ + choices: [{ message: { content: 'hello world' } }], + usage: { prompt_tokens: 11, completion_tokens: 7 }, + }); + + const { text, usage } = await dispatch('researcher', 'system-spec', 'do a thing', 'some ctx', route); + + expect(fn).toHaveBeenCalledTimes(1); + const [url, opts] = fn.mock.calls[0]; + expect(url).toBe('https://integrate.api.nvidia.com/v1/chat/completions'); + expect(opts.headers.Authorization).toBe('Bearer test-key'); + const sent = JSON.parse(opts.body); + expect(sent.model).toBe('meta/llama-3.3-70b-instruct'); + expect(sent.messages[0]).toEqual({ role: 'system', content: 'system-spec' }); + expect(sent.messages[1].role).toBe('user'); + expect(text).toBe('hello world'); + expect(usage.input_tokens).toBe(11); + expect(usage.output_tokens).toBe(7); + expect(usage.provider).toBe('openai'); + }); + + it('falls back to reasoning_content when content is empty (reasoning models)', async () => { + mockFetch({ choices: [{ message: { content: '', reasoning_content: 'the answer' } }] }); + const { text } = await dispatch('researcher', 'spec', 'task', '', route); + expect(text).toBe('the answer'); + }); + + it('strips a trailing slash on the base_url before appending the path', async () => { + process.env.OPENAI_BASE_URL = 'https://api.example.com/v1/'; + const fn = mockFetch({ choices: [{ message: { content: 'ok' } }] }); + await dispatch('researcher', 'spec', 'task', '', route); + expect(fn.mock.calls[0][0]).toBe('https://api.example.com/v1/chat/completions'); + }); + + it('throws including the HTTP status on a non-ok response', async () => { + mockFetch({ error: 'nope' }, { ok: false, status: 429 }); + await expect(dispatch('researcher', 'spec', 'task', '', route)).rejects.toThrow(/429/); + }); + + it('estimates usage when the response omits a usage block', async () => { + mockFetch({ choices: [{ message: { content: 'abcd' } }] }); + const { usage } = await dispatch('researcher', 'spec', 'task', '', route); + expect(usage.input_tokens).toBeGreaterThan(0); + expect(usage.output_tokens).toBeGreaterThan(0); + }); +});