Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
37 changes: 33 additions & 4 deletions cli/builtin-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ 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 codexUpstreamHeaders(req, upstream) {
const version = firstHeaderValue(req, 'version').trim() || DEFAULT_CODEX_VERSION;
const openaiBeta = firstHeaderValue(req, 'openai-beta').trim() || DEFAULT_OPENAI_BETA;
const originator = firstHeaderValue(req, 'originator').trim() || DEFAULT_CODEX_ORIGINATOR;
return {
...(upstream && upstream.authHeader ? { 'Authorization': upstream.authHeader } : {}),
'User-Agent': resolveCodexUserAgent(req),
'Version': version,
'OpenAI-Beta': openaiBeta,
'Originator': originator,
'X-Codexmate-Proxy': '1'
};
}

function readRequestBody(req, maxBytes) {
return new Promise((resolve) => {
let body = '';
Expand Down Expand Up @@ -1813,10 +1845,7 @@ 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);
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/builtin-proxy-responses-shim.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,46 @@ function createNestedNamespace(depth, leafTool) {
return current;
}

test('builtin-proxy /v1/responses sends Codex client identity upstream', async () => {
let capturedHeaders = null;
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/responses' && req.method === 'POST') {
capturedHeaders = req.headers;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'resp_test', model: 'gpt-5', output: [] }));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});

let proxyRuntime;
try {
const { port: upstreamPort } = await listen(upstream);
proxyRuntime = await startTestProxy(upstreamPort);
const proxyPort = proxyRuntime.server.address().port;

const resp = await requestText(`http://127.0.0.1:${proxyPort}/v1/responses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { model: 'gpt-5', input: 'hello', stream: false }
});

assert.equal(resp.status, 200);
assert.ok(capturedHeaders, 'upstream should receive /v1/responses request');
assert.match(capturedHeaders['user-agent'] || '', /^codex_cli_rs\//);
assert.equal(capturedHeaders.version, '0.98.0');
assert.equal(capturedHeaders['openai-beta'], 'responses=experimental');
assert.equal(capturedHeaders.originator, 'codex_cli_rs');
assert.equal(capturedHeaders['x-codexmate-proxy'], '1');
} finally {
if (proxyRuntime) {
await closeServer(proxyRuntime.server, proxyRuntime.connections);
}
await closeServer(upstream);
}
});

test('builtin-proxy /v1/responses falls back to chat-only upstream and returns Responses JSON', async () => {
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/responses' && req.method === 'POST') {
Expand Down Expand Up @@ -384,13 +424,15 @@ test('builtin-proxy /v1/responses falls back to chat when upstream responses tim

test('builtin-proxy /v1/responses stream=true streams chat fallback as Responses SSE', async () => {
let capturedChatRequest = null;
let capturedChatHeaders = null;
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/responses' && req.method === 'POST') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'responses endpoint unavailable' }));
return;
}
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
capturedChatHeaders = req.headers;
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
Expand Down Expand Up @@ -420,6 +462,10 @@ test('builtin-proxy /v1/responses stream=true streams chat fallback as Responses
assert.equal(sse.status, 200);
assert.ok(capturedChatRequest, 'streaming chat fallback should be called');
assert.equal(capturedChatRequest.stream, true);
assert.match(capturedChatHeaders['user-agent'] || '', /^codex_cli_rs\//);
assert.equal(capturedChatHeaders.version, '0.98.0');
assert.equal(capturedChatHeaders['openai-beta'], 'responses=experimental');
assert.equal(capturedChatHeaders.originator, 'codex_cli_rs');
assert.match(sse.headers['content-type'], /text\/event-stream/i);
assert.match(sse.text, /event: response\.created/);
assert.match(sse.text, /event: response\.output_text\.delta/);
Expand Down
16 changes: 8 additions & 8 deletions tests/unit/claude-settings-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ test('buildClaudeImportedConfigName derives host-based fallback name', () => {
});


test('addClaudeConfig requires a visible model value before saving', () => {
test('addClaudeConfig requires a visible model value before saving', async () => {
const source = extractClaudeMethodAsFunction(appSource, 'addClaudeConfig');
const addClaudeConfig = instantiateFunction(source, 'addClaudeConfig');
const messages = [];
Expand All @@ -263,7 +263,7 @@ test('addClaudeConfig requires a visible model value before saving', () => {
refreshClaudeModelContext() {}
};

addClaudeConfig.call(context);
await addClaudeConfig.call(context);

assert.deepStrictEqual(messages, [{ text: '模型名称必填', type: 'error' }]);
assert.deepStrictEqual(context.claudeConfigs, {});
Expand Down Expand Up @@ -406,13 +406,13 @@ test('openEditConfigModal carries external credential metadata into edit validat
assert.strictEqual(context.showEditConfigModal, true);
});

test('addClaudeConfig trims and persists the entered model', () => {
test('addClaudeConfig trims, persists, and applies the entered model', async () => {
const source = extractClaudeMethodAsFunction(appSource, 'addClaudeConfig');
const addClaudeConfig = instantiateFunction(source, 'addClaudeConfig');
const messages = [];
let saveCount = 0;
let closed = false;
let refreshed = false;
const applied = [];
const i18nMethods = createI18nMethods();
const context = {
...i18nMethods,
Expand All @@ -430,17 +430,17 @@ test('addClaudeConfig trims and persists the entered model', () => {
mergeClaudeConfig: (_, cfg) => ({ ...cfg }),
saveClaudeConfigs() { saveCount += 1; },
closeClaudeConfigModal() { closed = true; },
refreshClaudeModelContext() { refreshed = true; }
async applyClaudeConfig(name) { applied.push(name); }
};

addClaudeConfig.call(context);
await addClaudeConfig.call(context);

assert.strictEqual(context.currentClaudeConfig, 'Claude Test');
assert.strictEqual(context.claudeConfigs['Claude Test'].model, 'claude-test-model');
assert.strictEqual(saveCount, 1);
assert.strictEqual(closed, true);
assert.strictEqual(refreshed, true);
assert.deepStrictEqual(messages, [{ text: '操作成功', type: 'success' }]);
assert.deepStrictEqual(applied, ['Claude Test']);
assert.deepStrictEqual(messages, []);
});

test('ensureClaudeConfigFromSettings creates imported config for unmatched Claude settings', () => {
Expand Down
5 changes: 2 additions & 3 deletions web-ui/modules/app.methods.claude-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export function createClaudeConfigMethods(options = {}) {
}
},

addClaudeConfig() {
async addClaudeConfig() {
const validation = getClaudeConfigValidationForContext(this, 'add');
if (!validation.ok) {
return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error');
Expand All @@ -252,9 +252,8 @@ export function createClaudeConfigMethods(options = {}) {

this.currentClaudeConfig = name;
this.saveClaudeConfigs();
this.showMessage(this.t('toast.operation.success'), 'success');
this.closeClaudeConfigModal();
this.refreshClaudeModelContext();
await this.applyClaudeConfig(name);
},

async deleteClaudeConfig(name) {
Expand Down
Loading