Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Have you ever felt overwhelmed by managing multiple local AI agents? Each has it

Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
- **Unified Session Browser**: Search, inspect, filter, and export local sessions across Codex, Claude Code, Gemini CLI, and CodeBuddy Code from one place.
- **OpenAI-Compatible Bridge**: Use Codex with any OpenAI-compatible UI by normalizing the Responses API.
- **OpenAI-Compatible Bridge**: Use Codex with any OpenAI-compatible UI by normalizing the Responses API; the built-in Codex conversion also fills and normalizes Codex fingerprint headers such as `User-Agent`, `Version`, `OpenAI-Beta`, and `Originator` so upstream providers see an official Codex CLI-shaped request.
- **Claude Provider Bridge**: Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama through the built-in local Claude-compatible proxy.
- **OpenCode Provider Control**: Manage OpenCode provider/model selection with a CodexMate-owned provider store under `~/.codexmate`, projecting only the active provider into native OpenCode config to avoid polluting or deleting user-owned settings.
- **Skills Marketplace**: A local-first market to share and import skills between different agent apps.
Expand All @@ -73,7 +73,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
| **Usage Analytics** | ✅ | Visualize message trends and top projects |
| **Local Skills Market** | ✅ | Cross-app import/export of agent skills |
| **Task Queue** | ✅ | DAG-based task execution and logs |
| **OpenAI Bridge** | ✅ | Convert Codex Responses API to standard OpenAI format |
| **OpenAI Bridge** | ✅ | Convert Codex Responses API to standard OpenAI format and attach/normalize Codex fingerprints in the built-in conversion |
| **Claude Provider Bridge** | ✅ | Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama via the built-in Claude-compatible proxy |
| **OpenCode Provider Store** | ✅ | Keep multiple OpenCode providers in `~/.codexmate` while projecting only the selected provider to native OpenCode config |
| **Prompt Templates** | ✅ | Reusable prompt plugins with variables |
Expand Down
4 changes: 2 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

不同于简单的封装,Codex Mate 充当了 **本地智能体桥接器**:
- **统一会话浏览器**:在一个地方检索、预览、筛选并导出 Codex、Claude Code、Gemini CLI 与 CodeBuddy Code 的本地会话。
- **OpenAI 兼容桥接**:通过归一化 Responses API,让 Codex 能够与任何支持 OpenAI 格式的 UI 配合使用。
- **OpenAI 兼容桥接**:通过归一化 Responses API,让 Codex 能够与任何支持 OpenAI 格式的 UI 配合使用;内建 Codex 转换会补齐并规范化 `User-Agent`、`Version`、`OpenAI-Beta`、`Originator` 等 Codex 指纹,对上游伪装为官方 Codex CLI 请求。
- **Claude Provider 桥接**:通过内建本地 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama。
- **OpenCode Provider 控制**:在 `~/.codexmate` 下维护 CodexMate 自有的 OpenCode 多 provider 存储,只将当前选中的 provider 投影到 OpenCode 原生配置,避免污染或误删用户已有配置。
- **Skills 市场**:本地优先的市场,支持在不同的智能体应用之间共享和导入 Skills。
Expand All @@ -73,7 +73,7 @@
| **Usage 统计** | ✅ | 可视化消息趋势与热门项目统计 |
| **本地 Skills 市场** | ✅ | 跨应用的智能体 Skills 导入与导出 |
| **任务队列** | ✅ | 基于 DAG 的任务执行与日志查看 |
| **OpenAI 桥接** | ✅ | 将 Codex Responses API 转换为标准 OpenAI 格式 |
| **OpenAI 桥接** | ✅ | 将 Codex Responses API 转换为标准 OpenAI 格式,并在内建转换中附加/规范化 Codex 指纹 |
| **Claude Provider 桥接** | ✅ | 通过内建 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama |
| **OpenCode Provider 存储** | ✅ | 在 `~/.codexmate` 中保留多个 OpenCode provider,只将当前选中的 provider 投影到 OpenCode 原生配置 |
| **提示词模板** | ✅ | 支持变量的可复用提示词插件 |
Expand Down
193 changes: 187 additions & 6 deletions cli/builtin-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ function createBuiltinProxyRuntimeController(deps = {}) {

let runtime = null;

const DEFAULT_CODEX_VERSION = '0.98.0';
const DEFAULT_CODEX_USER_AGENT = 'codex_cli_rs/0.98.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
const DEFAULT_CODEX_ORIGINATOR = 'codex_cli_rs';
const DEFAULT_OPENAI_BETA = 'responses=experimental';

function firstHeaderValue(req, name) {
if (!req || !req.headers || typeof req.headers !== 'object') return '';
const value = req.headers[String(name || '').toLowerCase()];
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : '';
return typeof value === 'string' ? value : '';
}

function resolveCodexUserAgent(req) {
const incoming = firstHeaderValue(req, 'user-agent').trim();
if (/^(codex_cli_rs|codex-cli)\//i.test(incoming)) return incoming;
return DEFAULT_CODEX_USER_AGENT;
}

function resolveCodexOriginator() {
// Some Codex-only upstreams validate Originator separately from User-Agent.
// The local TUI may send values such as `codex-tui`, but upstream Codex
// gates commonly expect the official Rust CLI originator token.
return DEFAULT_CODEX_ORIGINATOR;
}

function codexUpstreamHeaders(req, upstream) {
const version = firstHeaderValue(req, 'version').trim() || DEFAULT_CODEX_VERSION;
const openaiBeta = firstHeaderValue(req, 'openai-beta').trim() || DEFAULT_OPENAI_BETA;
return {
...(upstream && upstream.authHeader ? { 'Authorization': upstream.authHeader } : {}),
'User-Agent': resolveCodexUserAgent(req),
'Version': version,
'OpenAI-Beta': openaiBeta,
'Originator': resolveCodexOriginator(),
'X-Codexmate-Proxy': '1'
};
}

function readRequestBody(req, maxBytes) {
return new Promise((resolve) => {
let body = '';
Expand Down Expand Up @@ -1427,6 +1465,127 @@ function createBuiltinProxyRuntimeController(deps = {}) {
});
}

function streamResponsesSse(targetUrl, options = {}) {
const parsed = new URL(targetUrl);
const transport = parsed.protocol === 'https:' ? https : http;
const bodyText = options.body ? JSON.stringify(options.body) : '';
const headers = {
'Accept': 'text/event-stream',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {})
};
if (options.body) {
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
}
const timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(1000, Number(options.timeoutMs))
: 30000;
const res = options.res;

return new Promise((resolve) => {
let settled = false;
let streamAccepted = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
const req = transport.request({
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
method: options.method || 'POST',
path: `${parsed.pathname}${parsed.search}`,
headers,
agent: parsed.protocol === 'https:' ? HTTPS_KEEP_ALIVE_AGENT : HTTP_KEEP_ALIVE_AGENT
}, (upstreamRes) => {
const status = upstreamRes.statusCode || 0;
const chunks = [];
const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');

const collectBody = (done) => {
upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
upstreamRes.on('end', () => done(chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''));
};

upstreamRes.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'upstream stream failed' }));
upstreamRes.on('aborted', () => finish({ ok: false, retryTransient: true, error: 'upstream stream aborted' }));

if (status === 404 || status === 405) {
collectBody((bodyTextResult) => finish({ retry: true, status, bodyText: bodyTextResult }));
return;
}

if (status >= 400) {
collectBody((bodyTextResult) => finish({ ok: false, status, bodyText: bodyTextResult }));
return;
}

if (/text\/event-stream/i.test(contentType)) {
streamAccepted = true;
req.setTimeout(0);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
upstreamRes.on('data', (chunk) => {
if (chunk && !res.writableEnded) res.write(chunk);
});
upstreamRes.on('end', () => {
if (!res.writableEnded) res.end();
finish({ ok: true });
});
return;
}

collectBody((text) => {
const parsedJson = parseJsonOrError(text);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
if (parsedJson.error) {
writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
writeSse(res, 'done', '[DONE]');
res.end();
finish({ ok: true });
return;
}
sendResponsesSse(res, ensureResponseMetadata(parsedJson.value));
res.end();
finish({ ok: true });
});
});
req.setTimeout(timeoutMs, () => {
if (streamAccepted) return;
try { req.destroy(new Error('timeout')); } catch (_) {}
finish({ ok: false, error: 'timeout' });
});
req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
if (bodyText) req.write(bodyText);
req.end();
});
}

async function streamResponsesSseWithFallbackUrls(baseUrl, pathSuffix, options = {}) {
const urls = buildUpstreamUrlCandidates(baseUrl, pathSuffix);
if (urls.length === 0) {
return { ok: false, error: 'failed to build upstream URL' };
}
let lastResult = null;
for (const url of urls) {
const result = await retryTransientRequest(() => streamResponsesSse(url, options));
lastResult = result;
if (result && result.retry) continue;
return result;
}
return lastResult || { ok: false, error: 'failed to build upstream URL' };
}

async function streamChatCompletionsAsResponsesSseWithFallbackUrls(baseUrl, pathSuffix, options = {}) {
const urls = buildUpstreamUrlCandidates(baseUrl, pathSuffix);
if (urls.length === 0) {
Expand Down Expand Up @@ -1793,8 +1952,8 @@ function createBuiltinProxyRuntimeController(deps = {}) {

// Responses shim:
// - Codex CLI 默认走 /v1/responses(含 SSE)
// - SSE/streaming 任务优先走 chat/completions fallback,避免卡在会接收但不产出 Responses 的兼容网关
// - 非流式请求仍优先尝试 /v1/responses(stream=false),失败再转换到 chat/completions 并回包为 responses。
// - SSE/streaming 任务优先保持 /v1/responses,避免 Codex-only upstream 把 fallback 后的 chat/completions 链路误判为非 Codex 客户端
// - 仅在上游明确不支持 Responses 时,才转换到 chat/completions 并回包为 responses。
if ((incomingPath === '/v1/responses' || incomingPath === '/v1/responses/') && (req.method || 'GET').toUpperCase() === 'POST') {
void (async () => {
const { body, error } = await readRequestBody(req, 10 * 1024 * 1024);
Expand All @@ -1813,16 +1972,38 @@ function createBuiltinProxyRuntimeController(deps = {}) {
const payload = parsed.value && typeof parsed.value === 'object' ? parsed.value : {};
const wantsStream = payload.stream === true;

const commonHeaders = {
...(upstream.authHeader ? { 'Authorization': upstream.authHeader } : {}),
'X-Codexmate-Proxy': '1'
};
const commonHeaders = codexUpstreamHeaders(req, upstream);

const model = typeof payload.model === 'string' ? payload.model : '';
const chatBody = buildChatCompletionsBodyFromResponsesPayload(payload);
const toolTypesByName = collectResponsesToolTypesByName(payload.tools);

if (wantsStream) {
const streamedResponses = await streamResponsesSseWithFallbackUrls(upstream.baseUrl, 'responses', {
method: 'POST',
headers: commonHeaders,
timeoutMs,
body: { ...payload, stream: true },
res,
model
});
if (streamedResponses.ok) return;
const canFallbackToChat = streamedResponses.status
? shouldFallbackFromUpstreamResponses(streamedResponses.status, streamedResponses.bodyText)
: false;
if (!canFallbackToChat) {
if (!res.headersSent) {
const status = streamedResponses.status && streamedResponses.status >= 400 ? streamedResponses.status : 502;
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(streamedResponses.bodyText || JSON.stringify({ error: streamedResponses.error || 'proxy request failed' }));
} else if (!res.writableEnded) {
writeSse(res, 'response.failed', { type: 'response.failed', error: streamedResponses.error || streamedResponses.bodyText || 'proxy request failed' });
writeSse(res, 'done', '[DONE]');
res.end();
}
return;
}

const streamingChatBody = { ...chatBody, stream: true };
const streamed = await streamChatCompletionsAsResponsesSseWithFallbackUrls(upstream.baseUrl, 'chat/completions', {
method: 'POST',
Expand Down
Loading
Loading