mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 18:24:54 +00:00
fix(proxy): preserve settings-profile launches for openai bridge
This commit is contained in:
+10
-1
@@ -82,6 +82,7 @@ import { tryHandleRootCommand } from './commands/root-command-router';
|
|||||||
// Import extracted utility functions
|
// Import extracted utility functions
|
||||||
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor';
|
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor';
|
||||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||||
|
import { createOpenAICompatLaunchSettingsPath } from './utils/openai-compat-launch-settings';
|
||||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||||
import { createLogger } from './services/logging';
|
import { createLogger } from './services/logging';
|
||||||
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
||||||
@@ -1459,8 +1460,16 @@ async function main(): Promise<void> {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
delete proxyEnv.ANTHROPIC_API_KEY;
|
delete proxyEnv.ANTHROPIC_API_KEY;
|
||||||
|
const launchSettingsPath = createOpenAICompatLaunchSettingsPath(
|
||||||
|
expandedSettingsPath,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
|
||||||
const launchArgs = [...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
const launchArgs = [
|
||||||
|
'--settings',
|
||||||
|
launchSettingsPath,
|
||||||
|
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||||
|
];
|
||||||
const traceEnv = createWebSearchTraceContext({
|
const traceEnv = createWebSearchTraceContext({
|
||||||
launcher: 'ccs.settings-profile.proxy',
|
launcher: 'ccs.settings-profile.proxy',
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
stripAnthropicRoutingEnv,
|
stripAnthropicRoutingEnv,
|
||||||
stripClaudeCodeEnv,
|
stripClaudeCodeEnv,
|
||||||
} from '../utils/shell-executor';
|
} from '../utils/shell-executor';
|
||||||
|
import { createOpenAICompatLaunchSettingsPath } from '../utils/openai-compat-launch-settings';
|
||||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
||||||
import {
|
import {
|
||||||
appendThirdPartyImageAnalysisToolArgs,
|
appendThirdPartyImageAnalysisToolArgs,
|
||||||
@@ -255,8 +256,12 @@ export class HeadlessExecutor {
|
|||||||
// Smart slash command detection and preservation
|
// Smart slash command detection and preservation
|
||||||
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
||||||
|
|
||||||
|
const launchSettingsPath = openAICompatProfile
|
||||||
|
? createOpenAICompatLaunchSettingsPath(settingsPath, settings)
|
||||||
|
: settingsPath;
|
||||||
|
|
||||||
// Prepare arguments
|
// Prepare arguments
|
||||||
const args: string[] = ['-p', processedPrompt, '--settings', settingsPath];
|
const args: string[] = ['-p', processedPrompt, '--settings', launchSettingsPath];
|
||||||
|
|
||||||
// Always use stream-json for real-time progress visibility
|
// Always use stream-json for real-time progress visibility
|
||||||
args.push('--output-format', 'stream-json', '--verbose');
|
args.push('--output-format', 'stream-json', '--verbose');
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import type { Settings } from '../types/config';
|
||||||
|
import { stripAnthropicRoutingEnv } from './shell-executor';
|
||||||
|
|
||||||
|
export function createOpenAICompatLaunchSettingsPath(
|
||||||
|
settingsPath: string,
|
||||||
|
settings: Settings
|
||||||
|
): string {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-settings-'));
|
||||||
|
fs.chmodSync(tempDir, 0o700);
|
||||||
|
|
||||||
|
const launchSettings = JSON.parse(JSON.stringify(settings)) as Settings;
|
||||||
|
const sanitizedEnv = Object.fromEntries(
|
||||||
|
Object.entries(stripAnthropicRoutingEnv({ ...(launchSettings.env ?? {}) })).filter(
|
||||||
|
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(sanitizedEnv).length > 0) {
|
||||||
|
launchSettings.env = sanitizedEnv;
|
||||||
|
} else {
|
||||||
|
delete launchSettings.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchSettingsPath = path.join(tempDir, path.basename(settingsPath));
|
||||||
|
fs.writeFileSync(launchSettingsPath, JSON.stringify(launchSettings, null, 2) + '\n', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
|
||||||
|
return launchSettingsPath;
|
||||||
|
}
|
||||||
@@ -299,7 +299,58 @@ describe('openai proxy request routing', () => {
|
|||||||
expect(bodies[0]?.body).toMatchObject({
|
expect(bodies[0]?.body).toMatchObject({
|
||||||
model: 'deepseek-reasoner',
|
model: 'deepseek-reasoner',
|
||||||
reasoning_effort: 'high',
|
reasoning_effort: 'high',
|
||||||
reasoning: { enabled: true, effort: 'high' },
|
|
||||||
});
|
});
|
||||||
|
expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards adaptive thinking to openai-profile upstreams via reasoning_effort only', async () => {
|
||||||
|
const hits: string[] = [];
|
||||||
|
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||||
|
const upstreamPort = await startMockUpstream('openai', hits, bodies);
|
||||||
|
|
||||||
|
const settingsPath = writeSettings('openai', {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'openai_token',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, '.ccs', 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { openai: settingsPath } }, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile: OpenAICompatProfileConfig = {
|
||||||
|
profileName: 'openai',
|
||||||
|
settingsPath,
|
||||||
|
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
apiKey: 'openai_token',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
};
|
||||||
|
proxyServer = startOpenAICompatProxyServer({
|
||||||
|
profile,
|
||||||
|
port: 0,
|
||||||
|
authToken: 'test-proxy-token',
|
||||||
|
});
|
||||||
|
proxyPort = await waitForServerListening(proxyServer);
|
||||||
|
|
||||||
|
const response = await requestProxy({
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
thinking: { type: 'adaptive' },
|
||||||
|
output_config: { effort: 'max' },
|
||||||
|
messages: [{ role: 'user', content: 'think adaptively' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
content: [{ type: 'text', text: 'Reply from openai' }],
|
||||||
|
});
|
||||||
|
expect(hits).toEqual(['openai']);
|
||||||
|
expect(bodies[0]?.body).toMatchObject({
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
reasoning_effort: 'high',
|
||||||
|
});
|
||||||
|
expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||||
|
import { stopOpenAICompatProxy } from '../../../src/proxy/proxy-daemon';
|
||||||
|
|
||||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
||||||
setDefaultTimeout(30000);
|
setDefaultTimeout(30000);
|
||||||
@@ -30,6 +31,14 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLaunchedArgs(argsLogPath: string): string[] {
|
||||||
|
return fs
|
||||||
|
.readFileSync(argsLogPath, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.map((arg) => arg.trim())
|
||||||
|
.filter((arg) => arg.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function reserveClosedPort(): number {
|
function reserveClosedPort(): number {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -167,7 +176,7 @@ exit 0
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
if (devtoolsServer) {
|
if (devtoolsServer) {
|
||||||
devtoolsServer.kill();
|
devtoolsServer.kill();
|
||||||
devtoolsServer = undefined;
|
devtoolsServer = undefined;
|
||||||
@@ -176,6 +185,7 @@ exit 0
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await stopOpenAICompatProxy();
|
||||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +267,74 @@ exit 0
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a sanitized settings copy for local OpenAI-compatible proxy launches', () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'profile-token',
|
||||||
|
ANTHROPIC_API_KEY: 'profile-api-key',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
|
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'Read',
|
||||||
|
hooks: [{ type: 'command', command: 'echo keep-profile-settings' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
|
||||||
|
const launchedArgs = readLaunchedArgs(claudeArgsLogPath);
|
||||||
|
const settingsIndex = launchedArgs.indexOf('--settings');
|
||||||
|
expect(settingsIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
const launchSettingsPath = launchedArgs[settingsIndex + 1];
|
||||||
|
expect(launchSettingsPath).toBeDefined();
|
||||||
|
expect(launchSettingsPath).not.toBe(settingsPath);
|
||||||
|
|
||||||
|
const persistedLaunchSettings = JSON.parse(
|
||||||
|
fs.readFileSync(launchSettingsPath as string, 'utf8')
|
||||||
|
) as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
hooks?: {
|
||||||
|
PreToolUse?: Array<{
|
||||||
|
matcher?: string;
|
||||||
|
hooks?: Array<{ command?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||||
|
expect(
|
||||||
|
persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command
|
||||||
|
).toBe('echo keep-profile-settings');
|
||||||
|
|
||||||
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
|
expect(launchedEnv).toContain('stripAnthropic=');
|
||||||
|
expect(launchedEnv).toContain('anthropicBaseUrl=http://127.0.0.1:');
|
||||||
|
expect(launchedEnv).not.toContain('anthropicBaseUrl=https://api.openai.com/v1');
|
||||||
|
expect(launchedEnv).toContain('anthropicModel=gpt-5.4');
|
||||||
|
expect(launchedEnv).toContain('maxOutputTokens=12345');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => {
|
it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => {
|
||||||
if (process.platform === 'win32') return;
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
|||||||
@@ -651,6 +651,14 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'Read',
|
||||||
|
hooks: [{ type: 'command', command: 'echo headless-bridge-hook' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -679,6 +687,32 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token');
|
expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token');
|
||||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||||
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
|
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
|
||||||
|
|
||||||
|
const args = spawnCalls[0].args;
|
||||||
|
const settingsIndex = args.indexOf('--settings');
|
||||||
|
expect(settingsIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
const launchSettingsPath = args[settingsIndex + 1];
|
||||||
|
expect(launchSettingsPath).toBeDefined();
|
||||||
|
expect(launchSettingsPath).not.toBe(path.join(ccsDir, 'bridge.settings.json'));
|
||||||
|
|
||||||
|
const persistedLaunchSettings = JSON.parse(fs.readFileSync(launchSettingsPath, 'utf8')) as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
hooks?: {
|
||||||
|
PreToolUse?: Array<{
|
||||||
|
matcher?: string;
|
||||||
|
hooks?: Array<{ command?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
|
||||||
|
expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||||
|
expect(
|
||||||
|
persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command
|
||||||
|
).toBe('echo headless-bridge-hook');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user