mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
* feat(cliproxy): add HTTPS tunnel for remote proxy mode Claude Code doesn't support HTTPS in ANTHROPIC_BASE_URL directly (undici limitation). This adds an HTTP→HTTPS tunnel proxy for remote CLIProxyAPI connections. Changes: - Add HttpsTunnelProxy: local HTTP server tunneling to remote HTTPS - Add CodexReasoningProxy HTTPS support and path prefix stripping - Remote mode now uses root paths (/v1/messages) not provider-prefixed - Add remote token uploader for syncing OAuth tokens to remote server - Auto-upload tokens after OAuth auth when remote mode is enabled Flow for remote HTTPS: Claude CLI → CodexReasoningProxy → HttpsTunnel → Remote HTTPS Built with [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai> * fix: address PR review issues for HTTPS tunnel proxy - Add connection tracking with activeConnections Set for proper cleanup - Add port validation after start() to reject port 0 - Add Authorization header fallback injection in buildForwardHeaders() - Handle client disconnect (premature close) and request errors - Improve error handling in uploadTokenToRemoteAsync (log instead of silent catch) - Add comprehensive tests for HttpsTunnelProxy (97% coverage) - Add integration tests for remote-token-uploader - Add stripPathPrefix unit tests for remote mode Built with [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai> * fix: bump Node.js engine requirement to >=18.0.0 FormData and Blob APIs used in remote-token-uploader require Node.js 18+. Addresses coderabbit review comment about Node.js engine compatibility. Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai> * fix: address remaining PR suggestions - Add verbose parameter to registerAccountFromToken for proper propagation - Improve stripPathPrefix with path normalization (double slashes, leading slash) - Add edge case tests for path normalization Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai> * fix: add timeout to flaky npm CLI tests Tests 'handles empty arguments gracefully' and 'handles very long argument' were missing timeout option in execSync, causing occasional timeouts when bun test's 5000ms limit was reached before CLI completed. Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai> * fix: address new PR review feedback - Add path segment boundary check to prevent partial matches (/codex vs /codextra) - Add hostname validation in HttpsTunnelProxy constructor - Add race condition protection with 'starting' flag in start() - Sanitize error messages (detailed only in verbose mode) - Update comments for clarity (regex behavior) - Add comprehensive tests for all edge cases Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai> --------- Co-authored-by: OnSteroids <built@onsteroids.ai>
198 lines
7.1 KiB
JavaScript
198 lines
7.1 KiB
JavaScript
const assert = require('assert');
|
|
|
|
const {
|
|
buildCodexModelEffortMap,
|
|
getEffortForModel,
|
|
injectReasoningEffortIntoBody,
|
|
} = require('../../../dist/cliproxy/codex-reasoning-proxy');
|
|
|
|
describe('Codex Reasoning Proxy', () => {
|
|
describe('buildCodexModelEffortMap', () => {
|
|
it('maps tier models to expected efforts', () => {
|
|
const map = buildCodexModelEffortMap({
|
|
defaultModel: 'gpt-5.2-codex',
|
|
opusModel: 'gpt-5.2-codex',
|
|
sonnetModel: 'gpt-5.2-codex-high',
|
|
haikuModel: 'gpt-5.2-codex-mini',
|
|
});
|
|
|
|
assert.strictEqual(map.get('gpt-5.2-codex'), 'xhigh');
|
|
assert.strictEqual(map.get('gpt-5.2-codex-high'), 'high');
|
|
assert.strictEqual(map.get('gpt-5.2-codex-mini'), 'medium');
|
|
});
|
|
|
|
it('uses the lowest effort when the same model appears in multiple tiers', () => {
|
|
const map = buildCodexModelEffortMap({
|
|
opusModel: 'same',
|
|
sonnetModel: 'same',
|
|
haikuModel: 'same',
|
|
});
|
|
|
|
assert.strictEqual(map.get('same'), 'medium');
|
|
});
|
|
|
|
it('returns empty map when all models undefined', () => {
|
|
const map = buildCodexModelEffortMap({});
|
|
assert.strictEqual(map.size, 0);
|
|
});
|
|
|
|
it('ignores empty string models', () => {
|
|
const map = buildCodexModelEffortMap({ defaultModel: '', opusModel: ' ' });
|
|
assert.strictEqual(map.size, 0);
|
|
});
|
|
});
|
|
|
|
describe('getEffortForModel', () => {
|
|
it('defaults to medium for unknown model', () => {
|
|
const map = buildCodexModelEffortMap({ opusModel: 'm1' });
|
|
assert.strictEqual(getEffortForModel('unknown', map, 'medium'), 'medium');
|
|
});
|
|
|
|
it('handles null model', () => {
|
|
const map = buildCodexModelEffortMap({ opusModel: 'm1' });
|
|
assert.strictEqual(getEffortForModel(null, map, 'high'), 'high');
|
|
});
|
|
});
|
|
|
|
describe('injectReasoningEffortIntoBody', () => {
|
|
it('overrides reasoning.effort on object bodies', () => {
|
|
const out = injectReasoningEffortIntoBody(
|
|
{ model: 'm1', reasoning: { effort: 'xhigh' } },
|
|
'high'
|
|
);
|
|
assert.deepStrictEqual(out, { model: 'm1', reasoning: { effort: 'high' } });
|
|
});
|
|
|
|
it('leaves non-object bodies unchanged', () => {
|
|
assert.strictEqual(injectReasoningEffortIntoBody('x', 'medium'), 'x');
|
|
});
|
|
|
|
it('leaves array bodies unchanged (not treated as record)', () => {
|
|
const arr = [1, 2, 3];
|
|
assert.deepStrictEqual(injectReasoningEffortIntoBody(arr, 'high'), arr);
|
|
});
|
|
});
|
|
|
|
describe('model suffix aliasing', () => {
|
|
it('lets callers use model-id suffixes without changing upstream model ids', () => {
|
|
// We can't call the full proxy in unit tests, but we can validate the intent:
|
|
// - injected effort is based on the suffix
|
|
// - callers should set request.model to the base model upstream (proxy does this)
|
|
const out = injectReasoningEffortIntoBody(
|
|
{ model: 'gpt-5.2-codex', reasoning: { effort: 'medium' } },
|
|
'xhigh'
|
|
);
|
|
assert.deepStrictEqual(out, {
|
|
model: 'gpt-5.2-codex',
|
|
reasoning: { effort: 'xhigh' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('stripPathPrefix (remote mode)', () => {
|
|
// Tests the path prefix stripping logic used in remote proxy mode
|
|
// Remote CLIProxyAPI expects /v1/messages, but Claude sends /api/provider/codex/v1/messages
|
|
|
|
/**
|
|
* Updated to match the version in codex-reasoning-proxy.ts with boundary check.
|
|
* Only strips if prefix matches a complete path segment (not partial like /codex matching /codextra)
|
|
*/
|
|
function stripPathPrefix(path, prefix) {
|
|
if (
|
|
prefix &&
|
|
path.startsWith(prefix) &&
|
|
(path.length === prefix.length || path[prefix.length] === '/')
|
|
) {
|
|
let stripped = path.slice(prefix.length);
|
|
// Normalize: collapse any leading slashes to single slash and ensure path starts with '/'
|
|
stripped = stripped.replace(/^\/+/, '/') || '/';
|
|
if (!stripped.startsWith('/')) {
|
|
stripped = '/' + stripped;
|
|
}
|
|
return stripped;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
it('strips /api/provider/codex prefix for remote mode', () => {
|
|
const result = stripPathPrefix('/api/provider/codex/v1/messages', '/api/provider/codex');
|
|
assert.strictEqual(result, '/v1/messages');
|
|
});
|
|
|
|
it('returns root path when prefix equals full path', () => {
|
|
const result = stripPathPrefix('/api/provider/codex', '/api/provider/codex');
|
|
assert.strictEqual(result, '/');
|
|
});
|
|
|
|
it('leaves path unchanged when prefix is undefined', () => {
|
|
const result = stripPathPrefix('/v1/messages', undefined);
|
|
assert.strictEqual(result, '/v1/messages');
|
|
});
|
|
|
|
it('leaves path unchanged when prefix does not match', () => {
|
|
const result = stripPathPrefix('/v1/messages', '/api/provider/codex');
|
|
assert.strictEqual(result, '/v1/messages');
|
|
});
|
|
|
|
it('handles empty prefix', () => {
|
|
const result = stripPathPrefix('/v1/messages', '');
|
|
assert.strictEqual(result, '/v1/messages');
|
|
});
|
|
|
|
it('handles various provider prefixes', () => {
|
|
// Gemini
|
|
assert.strictEqual(
|
|
stripPathPrefix('/api/provider/gemini/v1/chat', '/api/provider/gemini'),
|
|
'/v1/chat'
|
|
);
|
|
// Agy
|
|
assert.strictEqual(
|
|
stripPathPrefix('/api/provider/agy/v1/messages', '/api/provider/agy'),
|
|
'/v1/messages'
|
|
);
|
|
});
|
|
|
|
it('preserves query strings after stripping', () => {
|
|
const result = stripPathPrefix('/api/provider/codex/v1/messages?stream=true', '/api/provider/codex');
|
|
assert.strictEqual(result, '/v1/messages?stream=true');
|
|
});
|
|
|
|
// Edge cases for path normalization
|
|
it('collapses double slashes after stripping', () => {
|
|
// e.g., '/api/provider/codex//v1/messages' → '/v1/messages'
|
|
const result = stripPathPrefix('/api/provider/codex//v1/messages', '/api/provider/codex');
|
|
assert.strictEqual(result, '/v1/messages');
|
|
});
|
|
|
|
it('handles multiple leading slashes after stripping', () => {
|
|
// e.g., '/api/provider/codex///v1' → '/v1'
|
|
const result = stripPathPrefix('/api/provider/codex///v1', '/api/provider/codex');
|
|
assert.strictEqual(result, '/v1');
|
|
});
|
|
|
|
it('adds leading slash if missing after strip', () => {
|
|
const result = stripPathPrefix('/prefix/suffix', '/prefix');
|
|
assert.strictEqual(result, '/suffix');
|
|
});
|
|
|
|
// Boundary check tests - prevent partial segment matching
|
|
it('does NOT strip partial path segment matches', () => {
|
|
// /codex should NOT match /codextra
|
|
const result = stripPathPrefix('/api/provider/codextra/v1/messages', '/api/provider/codex');
|
|
assert.strictEqual(result, '/api/provider/codextra/v1/messages');
|
|
});
|
|
|
|
it('does NOT strip when prefix matches but next char is not slash', () => {
|
|
// /api should NOT match /api-v2
|
|
const result = stripPathPrefix('/api-v2/messages', '/api');
|
|
assert.strictEqual(result, '/api-v2/messages');
|
|
});
|
|
|
|
it('strips when prefix matches exactly with slash boundary', () => {
|
|
// /api/provider/codex should match /api/provider/codex/v1
|
|
const result = stripPathPrefix('/api/provider/codex/v1', '/api/provider/codex');
|
|
assert.strictEqual(result, '/v1');
|
|
});
|
|
});
|
|
});
|