Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
```
Expand Down
7 changes: 7 additions & 0 deletions config/providers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 30 additions & 12 deletions lib/llm-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
};
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} } }
);

Expand Down
80 changes: 80 additions & 0 deletions test/unit/llm-adapter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading