mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
refactor(cliproxy/executor): extract model-warnings + claude-launcher and polish orchestrator
Phases 08+09+10 of #1162. Final extractions and orchestrator cleanup: - src/cliproxy/executor/model-warnings.ts (80 LOC): warnBrokenModels — handles composite + simple paths, broken-model notification with replacement suggestions. - src/cliproxy/executor/claude-launcher.ts (161 LOC): launchClaude — final args assembly (web search, image analysis, browser tool args), trace context env, Windows shell escaping, spawn, quota monitor wiring, cleanup handlers. - New tests: 227 + 217 LOC. - index.ts cleanup: numbered section comments through orchestrator, removed ~17 now-unused imports, re-export block preserved. index.ts: 665 -> 550 LOC. Final reduction across all 10 phases: 1428 -> 550 (-878, -61%). Behavior unchanged; full suite passes 1824/1824. The remaining 550 LOC is the natural orchestrator floor — further extraction would require a 15-field context struct, which is manufactured complexity rather than genuine separation of concerns. Closes phases 02-10 of #1162. Refs #1162
This commit is contained in:
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Tests for claude-launcher.ts — Phase 09
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* - spawn called with expected args and env
|
||||||
|
* - Windows shell escaping path
|
||||||
|
* - Cleanup handlers registered (setupCleanupHandlers called)
|
||||||
|
*
|
||||||
|
* Strategy: mock child_process.spawn and all side-effectful dependencies so
|
||||||
|
* no real processes are started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test';
|
||||||
|
import type { ChildProcess } from 'child_process';
|
||||||
|
import type { ExecutorConfig } from '../../types';
|
||||||
|
|
||||||
|
// ── Spawn mock ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockSpawnResult = {
|
||||||
|
pid: 42,
|
||||||
|
on: jest.fn(),
|
||||||
|
stdout: null,
|
||||||
|
stderr: null,
|
||||||
|
} as unknown as ChildProcess;
|
||||||
|
|
||||||
|
const mockSpawn = jest.fn().mockReturnValue(mockSpawnResult);
|
||||||
|
|
||||||
|
mock.module('child_process', () => ({
|
||||||
|
spawn: mockSpawn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Dependency mocks ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`);
|
||||||
|
const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe');
|
||||||
|
|
||||||
|
mock.module('../../utils/shell-executor', () => ({
|
||||||
|
escapeShellArg: mockEscapeShellArg,
|
||||||
|
getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../config/config-generator', () => ({
|
||||||
|
getProviderSettingsPath: jest.fn().mockReturnValue('/fake/.ccs/settings/gemini.json'),
|
||||||
|
CLIPROXY_DEFAULT_PORT: 8317,
|
||||||
|
generateConfig: jest.fn(),
|
||||||
|
getProviderConfig: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../utils/websearch-manager', () => ({
|
||||||
|
appendThirdPartyWebSearchToolArgs: (args: string[]) => args,
|
||||||
|
createWebSearchTraceContext: jest.fn().mockReturnValue({}),
|
||||||
|
ensureWebSearchMcpOrThrow: jest.fn(),
|
||||||
|
displayWebSearchStatus: jest.fn(),
|
||||||
|
getWebSearchHookEnv: jest.fn().mockReturnValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../utils/image-analysis', () => ({
|
||||||
|
appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'],
|
||||||
|
syncImageAnalysisMcpToConfigDir: jest.fn(),
|
||||||
|
ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../utils/browser', () => ({
|
||||||
|
appendBrowserToolArgs: (args: string[]) => [...args, '--browser'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../accounts/account-manager', () => ({
|
||||||
|
getDefaultAccount: jest.fn().mockReturnValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSetupCleanupHandlers = jest.fn();
|
||||||
|
mock.module('../session-bridge', () => ({
|
||||||
|
setupCleanupHandlers: mockSetupCleanupHandlers,
|
||||||
|
checkOrJoinProxy: jest.fn(),
|
||||||
|
registerProxySession: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockResolveRuntimeQuotaMonitorProviders = jest.fn().mockReturnValue([]);
|
||||||
|
mock.module('../account-resolution', () => ({
|
||||||
|
resolveRuntimeQuotaMonitorProviders: mockResolveRuntimeQuotaMonitorProviders,
|
||||||
|
resolveAccounts: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Dynamic import for quota-manager
|
||||||
|
mock.module('../quota/quota-manager', () => ({
|
||||||
|
startQuotaMonitor: jest.fn(),
|
||||||
|
stopQuotaMonitor: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Subject under test ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { launchClaude } from '../claude-launcher';
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||||
|
return {
|
||||||
|
port: 8317,
|
||||||
|
timeout: 5000,
|
||||||
|
verbose: false,
|
||||||
|
pollInterval: 100,
|
||||||
|
...overrides,
|
||||||
|
} as ExecutorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseContext(overrides: object = {}) {
|
||||||
|
return {
|
||||||
|
claudeCli: '/usr/local/bin/claude',
|
||||||
|
claudeArgs: ['chat', '--model', 'gemini-2.5-pro'],
|
||||||
|
env: { ANTHROPIC_BASE_URL: 'http://localhost:8317' } as NodeJS.ProcessEnv,
|
||||||
|
cfg: makeCfg(),
|
||||||
|
provider: 'gemini' as const,
|
||||||
|
compositeProviders: [] as string[],
|
||||||
|
skipLocalAuth: true,
|
||||||
|
sessionId: undefined,
|
||||||
|
imageAnalysisMcpReady: false,
|
||||||
|
browserRuntimeEnv: undefined,
|
||||||
|
inheritedClaudeConfigDir: undefined,
|
||||||
|
codexReasoningProxy: null,
|
||||||
|
toolSanitizationProxy: null,
|
||||||
|
httpsTunnel: null,
|
||||||
|
verbose: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('launchClaude', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSpawn.mockClear();
|
||||||
|
mockSetupCleanupHandlers.mockClear();
|
||||||
|
mockEscapeShellArg.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls spawn with claudeCli and includes --settings arg', async () => {
|
||||||
|
const ctx = baseContext();
|
||||||
|
await launchClaude(ctx);
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||||
|
const [cmd, spawnArgs] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||||
|
expect(cmd).toBe('/usr/local/bin/claude');
|
||||||
|
expect(spawnArgs).toContain('--settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits process env merged with provided env', async () => {
|
||||||
|
const ctx = baseContext({ env: { CUSTOM_KEY: 'custom-value' } as NodeJS.ProcessEnv });
|
||||||
|
await launchClaude(ctx);
|
||||||
|
|
||||||
|
const spawnOpts = mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv };
|
||||||
|
expect(spawnOpts.env?.CUSTOM_KEY).toBe('custom-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes stdio: inherit', async () => {
|
||||||
|
await launchClaude(baseContext());
|
||||||
|
const spawnOpts = mockSpawn.mock.calls[0][2] as { stdio: string };
|
||||||
|
expect(spawnOpts.stdio).toBe('inherit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends image analysis args when imageAnalysisMcpReady=true', async () => {
|
||||||
|
await launchClaude(baseContext({ imageAnalysisMcpReady: true }));
|
||||||
|
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||||
|
expect(spawnArgs).toContain('--mcp-image-analysis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not append image analysis args when imageAnalysisMcpReady=false', async () => {
|
||||||
|
await launchClaude(baseContext({ imageAnalysisMcpReady: false }));
|
||||||
|
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||||
|
expect(spawnArgs).not.toContain('--mcp-image-analysis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends browser tool args when browserRuntimeEnv is set', async () => {
|
||||||
|
await launchClaude(
|
||||||
|
baseContext({ browserRuntimeEnv: { CCS_BROWSER: '1' } as NodeJS.ProcessEnv })
|
||||||
|
);
|
||||||
|
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||||
|
expect(spawnArgs).toContain('--browser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers cleanup handlers after spawn', async () => {
|
||||||
|
await launchClaude(baseContext());
|
||||||
|
expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1);
|
||||||
|
// First arg should be the ChildProcess returned by spawn
|
||||||
|
expect(mockSetupCleanupHandlers.mock.calls[0][0]).toBe(mockSpawnResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the ChildProcess from spawn', async () => {
|
||||||
|
const result = await launchClaude(baseContext());
|
||||||
|
expect(result).toBe(mockSpawnResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Windows shell escaping', () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses shell mode for .cmd executables on Windows', async () => {
|
||||||
|
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' }));
|
||||||
|
const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean };
|
||||||
|
// shell property should be set (cmd.exe from mock)
|
||||||
|
expect(spawnOpts.shell).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips shell mode for non-script executables on Windows', async () => {
|
||||||
|
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.exe' }));
|
||||||
|
// spawn should be called with separate args array, not a shell cmd string
|
||||||
|
const [, secondArg] = mockSpawn.mock.calls[0];
|
||||||
|
expect(Array.isArray(secondArg)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Tests for model-warnings.ts — Phase 08
|
||||||
|
*
|
||||||
|
* Verifies that warnBrokenModels emits warnings for broken models and is
|
||||||
|
* silent for healthy ones, covering both simple and composite providers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||||
|
|
||||||
|
// ── Stubs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// getCurrentModel: return whatever the test configures
|
||||||
|
const mockGetCurrentModel = jest.fn<string | undefined, [string, string | undefined]>();
|
||||||
|
// isModelBroken: return false by default
|
||||||
|
const mockIsModelBroken = jest.fn<boolean, [string, string]>().mockReturnValue(false);
|
||||||
|
const mockGetModelIssueUrl = jest
|
||||||
|
.fn<string | undefined, [string, string]>()
|
||||||
|
.mockReturnValue(undefined);
|
||||||
|
const mockFindModel = jest
|
||||||
|
.fn<{ name: string } | undefined, [string, string]>()
|
||||||
|
.mockReturnValue(undefined);
|
||||||
|
const mockGetSuggestedReplacementModel = jest
|
||||||
|
.fn<string | undefined, [string, string]>()
|
||||||
|
.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
jest.mock('../model-warnings', () => {
|
||||||
|
// We test the real implementation — only mock its dependencies
|
||||||
|
return jest.requireActual('../model-warnings');
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../../config/model-config', () => ({
|
||||||
|
getCurrentModel: mockGetCurrentModel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../cliproxy/model-catalog', () => ({
|
||||||
|
isModelBroken: mockIsModelBroken,
|
||||||
|
getModelIssueUrl: mockGetModelIssueUrl,
|
||||||
|
findModel: mockFindModel,
|
||||||
|
getSuggestedReplacementModel: mockGetSuggestedReplacementModel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// We import from real path after jest.mock so actual module is used
|
||||||
|
import { warnBrokenModels } from '../model-warnings';
|
||||||
|
import type { ExecutorConfig } from '../../types';
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeSimpleCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||||
|
return {
|
||||||
|
port: 8317,
|
||||||
|
timeout: 5000,
|
||||||
|
verbose: false,
|
||||||
|
pollInterval: 100,
|
||||||
|
isComposite: false,
|
||||||
|
...overrides,
|
||||||
|
} as ExecutorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('warnBrokenModels', () => {
|
||||||
|
let errorSpy: ReturnType<typeof jest.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockGetCurrentModel.mockReset();
|
||||||
|
mockIsModelBroken.mockReturnValue(false);
|
||||||
|
mockGetModelIssueUrl.mockReturnValue(undefined);
|
||||||
|
mockFindModel.mockReturnValue(undefined);
|
||||||
|
mockGetSuggestedReplacementModel.mockReturnValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn when no model is configured', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn when model is healthy', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue('gemini-2.5-pro');
|
||||||
|
mockIsModelBroken.mockReturnValue(false);
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when model is broken (no replacement, no issue url)', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||||
|
mockIsModelBroken.mockReturnValue(true);
|
||||||
|
mockFindModel.mockReturnValue({ name: 'Gemini Old' } as { name: string });
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes('has known issues with Claude Code'))).toBe(true);
|
||||||
|
expect(calls.some((m) => m.includes('Tool calls will fail'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes replacement model suggestion when available', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||||
|
mockIsModelBroken.mockReturnValue(true);
|
||||||
|
mockGetSuggestedReplacementModel.mockReturnValue('gemini-2.5-pro');
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes('gemini-2.5-pro'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes tracking URL when issue url is available', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||||
|
mockIsModelBroken.mockReturnValue(true);
|
||||||
|
mockGetModelIssueUrl.mockReturnValue('https://github.com/issues/123');
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes('https://github.com/issues/123'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes remote proxy note when skipLocalAuth=true', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||||
|
mockIsModelBroken.mockReturnValue(true);
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes('remote proxy'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes --config suggestion when skipLocalAuth=false', () => {
|
||||||
|
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||||
|
mockIsModelBroken.mockReturnValue(true);
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeSimpleCfg(),
|
||||||
|
compositeProviders: [],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes('--config'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('composite variants', () => {
|
||||||
|
function makeCompositeCfg(): ExecutorConfig {
|
||||||
|
return {
|
||||||
|
port: 8317,
|
||||||
|
timeout: 5000,
|
||||||
|
verbose: false,
|
||||||
|
pollInterval: 100,
|
||||||
|
isComposite: true,
|
||||||
|
compositeTiers: {
|
||||||
|
opus: { provider: 'gemini', model: 'gemini-opus-old' },
|
||||||
|
sonnet: { provider: 'gemini', model: 'gemini-sonnet-ok' },
|
||||||
|
haiku: { provider: 'gemini', model: 'gemini-haiku-ok' },
|
||||||
|
},
|
||||||
|
} as unknown as ExecutorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('warns for broken composite tier', () => {
|
||||||
|
mockIsModelBroken.mockImplementation((_p, m) => m === 'gemini-opus-old');
|
||||||
|
mockFindModel.mockReturnValue({ name: 'Gemini Opus Old' } as { name: string });
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeCompositeCfg(),
|
||||||
|
compositeProviders: ['gemini'],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes('opus tier'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn for healthy composite tiers', () => {
|
||||||
|
mockIsModelBroken.mockReturnValue(false);
|
||||||
|
|
||||||
|
warnBrokenModels({
|
||||||
|
provider: 'gemini',
|
||||||
|
cfg: makeCompositeCfg(),
|
||||||
|
compositeProviders: ['gemini'],
|
||||||
|
skipLocalAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Claude Launcher — Concern H
|
||||||
|
*
|
||||||
|
* Handles final argument assembly, trace context injection, Windows shell
|
||||||
|
* escaping, spawning the Claude CLI process, starting the runtime quota
|
||||||
|
* monitor, and wiring up cleanup handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||||
|
import { getProviderSettingsPath } from '../config/config-generator';
|
||||||
|
import {
|
||||||
|
ensureWebSearchMcpOrThrow as _ensureWebSearchMcpOrThrow,
|
||||||
|
appendThirdPartyWebSearchToolArgs,
|
||||||
|
createWebSearchTraceContext,
|
||||||
|
} from '../../utils/websearch-manager';
|
||||||
|
import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis';
|
||||||
|
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||||
|
import { getDefaultAccount } from '../accounts/account-manager';
|
||||||
|
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||||
|
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||||
|
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||||
|
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||||
|
import { setupCleanupHandlers } from './session-bridge';
|
||||||
|
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||||
|
|
||||||
|
export interface ClaudeLaunchContext {
|
||||||
|
/** Path to the Claude CLI executable */
|
||||||
|
claudeCli: string;
|
||||||
|
/** Pre-filtered Claude args (CCS flags already stripped) */
|
||||||
|
claudeArgs: string[];
|
||||||
|
/** Fully assembled environment variables (without trace additions) */
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
/** Resolved executor config */
|
||||||
|
cfg: ExecutorConfig;
|
||||||
|
/** Active CLIProxy provider */
|
||||||
|
provider: CLIProxyProvider;
|
||||||
|
/** Providers derived from composite tiers (empty for simple providers) */
|
||||||
|
compositeProviders: CLIProxyProvider[];
|
||||||
|
/** Whether local OAuth was skipped (remote proxy auth in use) */
|
||||||
|
skipLocalAuth: boolean;
|
||||||
|
/** Session ID for cleanup tracking */
|
||||||
|
sessionId: string | undefined;
|
||||||
|
/** Whether image analysis MCP is ready */
|
||||||
|
imageAnalysisMcpReady: boolean;
|
||||||
|
/** Browser runtime environment variables (undefined if browser not active) */
|
||||||
|
browserRuntimeEnv: NodeJS.ProcessEnv | undefined;
|
||||||
|
/** Inherited Claude config dir for continuity */
|
||||||
|
inheritedClaudeConfigDir: string | undefined;
|
||||||
|
/** Active Codex reasoning proxy (if any) */
|
||||||
|
codexReasoningProxy: CodexReasoningProxy | null;
|
||||||
|
/** Active tool sanitization proxy (if any) */
|
||||||
|
toolSanitizationProxy: ToolSanitizationProxy | null;
|
||||||
|
/** Active HTTPS tunnel proxy (if any) */
|
||||||
|
httpsTunnel: HttpsTunnelProxy | null;
|
||||||
|
/** Whether verbose logging is enabled */
|
||||||
|
verbose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble final Claude CLI arguments, inject trace context, spawn the process,
|
||||||
|
* start the runtime quota monitor, and wire cleanup handlers.
|
||||||
|
*
|
||||||
|
* @returns The spawned ChildProcess for the Claude CLI.
|
||||||
|
*/
|
||||||
|
export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildProcess> {
|
||||||
|
const {
|
||||||
|
claudeCli,
|
||||||
|
claudeArgs,
|
||||||
|
env,
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
compositeProviders,
|
||||||
|
skipLocalAuth,
|
||||||
|
sessionId,
|
||||||
|
imageAnalysisMcpReady,
|
||||||
|
browserRuntimeEnv,
|
||||||
|
inheritedClaudeConfigDir,
|
||||||
|
codexReasoningProxy,
|
||||||
|
toolSanitizationProxy,
|
||||||
|
httpsTunnel,
|
||||||
|
verbose,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||||
|
|
||||||
|
const settingsPath = cfg.customSettingsPath
|
||||||
|
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||||
|
: getProviderSettingsPath(provider);
|
||||||
|
|
||||||
|
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||||
|
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||||
|
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||||
|
: claudeArgs;
|
||||||
|
const browserArgs = browserRuntimeEnv
|
||||||
|
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||||
|
: imageAnalysisArgs;
|
||||||
|
const launchArgs = [
|
||||||
|
'--settings',
|
||||||
|
settingsPath,
|
||||||
|
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Inject web search trace context into env
|
||||||
|
const traceEnv = createWebSearchTraceContext({
|
||||||
|
launcher: 'cliproxy.executor',
|
||||||
|
args: launchArgs,
|
||||||
|
profile: cfg.profileName || provider,
|
||||||
|
profileType: 'cliproxy',
|
||||||
|
settingsPath,
|
||||||
|
claudeConfigDir: inheritedClaudeConfigDir,
|
||||||
|
});
|
||||||
|
const tracedEnv = { ...env, ...traceEnv };
|
||||||
|
|
||||||
|
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
||||||
|
let claude: ChildProcess;
|
||||||
|
if (needsShell) {
|
||||||
|
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
||||||
|
claude = spawn(cmdString, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
|
shell: getWindowsEscapedCommandShell(),
|
||||||
|
env: tracedEnv,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
claude = spawn(claudeCli, launchArgs, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
|
env: tracedEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start runtime quota monitor (adaptive polling during session)
|
||||||
|
if (!skipLocalAuth) {
|
||||||
|
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
||||||
|
for (const monitorProvider of resolveRuntimeQuotaMonitorProviders(
|
||||||
|
provider,
|
||||||
|
compositeProviders
|
||||||
|
)) {
|
||||||
|
const monitorAccount = getDefaultAccount(monitorProvider);
|
||||||
|
if (monitorAccount) {
|
||||||
|
startQuotaMonitor(monitorProvider, monitorAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
||||||
|
setupCleanupHandlers(
|
||||||
|
claude,
|
||||||
|
sessionId,
|
||||||
|
cfg.port,
|
||||||
|
codexReasoningProxy,
|
||||||
|
toolSanitizationProxy,
|
||||||
|
httpsTunnel,
|
||||||
|
verbose
|
||||||
|
);
|
||||||
|
|
||||||
|
return claude;
|
||||||
|
}
|
||||||
+21
-132
@@ -10,41 +10,24 @@
|
|||||||
* 6. Claude CLI execution with cleanup handlers
|
* 6. Claude CLI execution with cleanup handlers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { ChildProcess } from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
|
||||||
import { fail, info, warn } from '../../utils/ui';
|
import { fail, info, warn } from '../../utils/ui';
|
||||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
|
||||||
import {
|
import {
|
||||||
generateConfig,
|
generateConfig,
|
||||||
getProviderConfig,
|
getProviderConfig,
|
||||||
getProviderSettingsPath,
|
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
} from '../config/config-generator';
|
} from '../config/config-generator';
|
||||||
import { configureProviderModel, getCurrentModel } from '../config/model-config';
|
import { configureProviderModel } from '../config/model-config';
|
||||||
import { supportsModelConfig } from '../model-catalog';
|
import { supportsModelConfig } from '../model-catalog';
|
||||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||||
import {
|
|
||||||
isModelBroken,
|
|
||||||
getModelIssueUrl,
|
|
||||||
findModel,
|
|
||||||
getSuggestedReplacementModel,
|
|
||||||
} from '../model-catalog';
|
|
||||||
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||||
import {
|
import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager';
|
||||||
ensureWebSearchMcpOrThrow,
|
|
||||||
displayWebSearchStatus,
|
|
||||||
appendThirdPartyWebSearchToolArgs,
|
|
||||||
createWebSearchTraceContext,
|
|
||||||
} from '../../utils/websearch-manager';
|
|
||||||
import {
|
import {
|
||||||
ensureImageAnalysisMcpOrThrow,
|
ensureImageAnalysisMcpOrThrow,
|
||||||
syncImageAnalysisMcpToConfigDir,
|
syncImageAnalysisMcpToConfigDir,
|
||||||
appendThirdPartyImageAnalysisToolArgs,
|
|
||||||
} from '../../utils/image-analysis';
|
} from '../../utils/image-analysis';
|
||||||
import { getDefaultAccount } from '../accounts/account-manager';
|
|
||||||
import { appendBrowserToolArgs } from '../../utils/browser';
|
|
||||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||||
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||||
@@ -61,7 +44,7 @@ import {
|
|||||||
logEnvironment,
|
logEnvironment,
|
||||||
resolveCliproxyImageAnalysisEnv,
|
resolveCliproxyImageAnalysisEnv,
|
||||||
} from './env-resolver';
|
} from './env-resolver';
|
||||||
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
import { checkOrJoinProxy, registerProxySession } from './session-bridge';
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||||
import {
|
import {
|
||||||
handleLogout,
|
handleLogout,
|
||||||
@@ -82,6 +65,8 @@ import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
|||||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||||
import { resolveExecutorProxy } from './proxy-resolver';
|
import { resolveExecutorProxy } from './proxy-resolver';
|
||||||
import { buildProxyChain } from './proxy-chain-builder';
|
import { buildProxyChain } from './proxy-chain-builder';
|
||||||
|
import { warnBrokenModels } from './model-warnings';
|
||||||
|
import { launchClaude } from './claude-launcher';
|
||||||
|
|
||||||
/** Local alias so internal call sites need no change */
|
/** Local alias so internal call sites need no change */
|
||||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||||
@@ -276,51 +261,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Check for broken models (multi-tier for composite)
|
// 5. Check for broken models (multi-tier for composite)
|
||||||
if (compositeProviders.length > 0 && cfg.compositeTiers) {
|
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
|
||||||
// Check all tier models in composite variant
|
|
||||||
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
|
||||||
for (const tier of tiers) {
|
|
||||||
const tierConfig = cfg.compositeTiers[tier];
|
|
||||||
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
|
||||||
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
|
||||||
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
|
||||||
console.error('');
|
|
||||||
console.error(
|
|
||||||
warn(
|
|
||||||
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
|
||||||
if (issueUrl) {
|
|
||||||
console.error(` Tracking: ${issueUrl}`);
|
|
||||||
}
|
|
||||||
console.error('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
|
||||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
|
||||||
const modelEntry = findModel(provider, currentModel);
|
|
||||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
|
||||||
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
|
||||||
console.error('');
|
|
||||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
|
||||||
if (replacementModel) {
|
|
||||||
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
|
||||||
} else {
|
|
||||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
|
||||||
}
|
|
||||||
if (issueUrl) {
|
|
||||||
console.error(` Tracking: ${issueUrl}`);
|
|
||||||
}
|
|
||||||
if (skipLocalAuth) {
|
|
||||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
|
||||||
} else {
|
|
||||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
|
||||||
}
|
|
||||||
console.error('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Ensure user settings file exists
|
// 6. Ensure user settings file exists
|
||||||
ensureProviderSettingsFile(provider);
|
ensureProviderSettingsFile(provider);
|
||||||
@@ -571,77 +512,25 @@ export async function execClaudeWithCLIProxy(
|
|||||||
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
|
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12. Filter CCS-specific flags before passing to Claude CLI
|
// 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup
|
||||||
const claudeArgs = filterCcsFlags(argsWithoutBrowserFlags);
|
const claudeArgs = filterCcsFlags(argsWithoutBrowserFlags);
|
||||||
|
await launchClaude({
|
||||||
const isWindows = process.platform === 'win32';
|
claudeCli,
|
||||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
claudeArgs,
|
||||||
|
env,
|
||||||
const settingsPath = cfg.customSettingsPath
|
cfg,
|
||||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
provider,
|
||||||
: getProviderSettingsPath(provider);
|
compositeProviders,
|
||||||
|
skipLocalAuth,
|
||||||
let claude: ChildProcess;
|
|
||||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
|
||||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
|
||||||
: claudeArgs;
|
|
||||||
const browserArgs = browserRuntimeEnv
|
|
||||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
|
||||||
: imageAnalysisArgs;
|
|
||||||
const launchArgs = [
|
|
||||||
'--settings',
|
|
||||||
settingsPath,
|
|
||||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
|
||||||
];
|
|
||||||
const traceEnv = createWebSearchTraceContext({
|
|
||||||
launcher: 'cliproxy.executor',
|
|
||||||
args: launchArgs,
|
|
||||||
profile: cfg.profileName || provider,
|
|
||||||
profileType: 'cliproxy',
|
|
||||||
settingsPath,
|
|
||||||
claudeConfigDir: inheritedClaudeConfigDir,
|
|
||||||
});
|
|
||||||
const tracedEnv = { ...env, ...traceEnv };
|
|
||||||
if (needsShell) {
|
|
||||||
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
|
||||||
claude = spawn(cmdString, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
windowsHide: true,
|
|
||||||
shell: getWindowsEscapedCommandShell(),
|
|
||||||
env: tracedEnv,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
claude = spawn(claudeCli, launchArgs, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
windowsHide: true,
|
|
||||||
env: tracedEnv,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 12b. Start runtime quota monitor (adaptive polling during session)
|
|
||||||
if (!skipLocalAuth) {
|
|
||||||
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
|
||||||
for (const monitorProvider of resolveRuntimeQuotaMonitorProviders(
|
|
||||||
provider,
|
|
||||||
compositeProviders
|
|
||||||
)) {
|
|
||||||
const monitorAccount = getDefaultAccount(monitorProvider);
|
|
||||||
if (monitorAccount) {
|
|
||||||
startQuotaMonitor(monitorProvider, monitorAccount.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 13. Setup cleanup handlers
|
|
||||||
setupCleanupHandlers(
|
|
||||||
claude,
|
|
||||||
sessionId,
|
sessionId,
|
||||||
cfg.port,
|
imageAnalysisMcpReady,
|
||||||
|
browserRuntimeEnv,
|
||||||
|
inheritedClaudeConfigDir,
|
||||||
codexReasoningProxy,
|
codexReasoningProxy,
|
||||||
toolSanitizationProxy,
|
toolSanitizationProxy,
|
||||||
httpsTunnel,
|
httpsTunnel,
|
||||||
verbose
|
verbose,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export utility functions for backwards compatibility
|
// Re-export utility functions for backwards compatibility
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Model Warnings — Concern G
|
||||||
|
*
|
||||||
|
* Emits console warnings when the active model (or any tier model in composite
|
||||||
|
* variants) is flagged as broken in the model catalog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { warn } from '../../utils/ui';
|
||||||
|
import { getCurrentModel } from '../config/model-config';
|
||||||
|
import {
|
||||||
|
isModelBroken,
|
||||||
|
getModelIssueUrl,
|
||||||
|
findModel,
|
||||||
|
getSuggestedReplacementModel,
|
||||||
|
} from '../model-catalog';
|
||||||
|
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||||
|
|
||||||
|
export interface ModelWarningsContext {
|
||||||
|
provider: CLIProxyProvider;
|
||||||
|
cfg: ExecutorConfig;
|
||||||
|
compositeProviders: CLIProxyProvider[];
|
||||||
|
skipLocalAuth: boolean;
|
||||||
|
customSettingsPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all active models for known issues and emit warnings.
|
||||||
|
*
|
||||||
|
* For composite variants, checks every tier model.
|
||||||
|
* For simple providers, checks the currently configured model.
|
||||||
|
*/
|
||||||
|
export function warnBrokenModels(context: ModelWarningsContext): void {
|
||||||
|
const { provider, cfg, skipLocalAuth } = context;
|
||||||
|
|
||||||
|
if (cfg.isComposite && cfg.compositeTiers) {
|
||||||
|
// Check all tier models in composite variant
|
||||||
|
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||||
|
for (const tier of tiers) {
|
||||||
|
const tierConfig = cfg.compositeTiers[tier];
|
||||||
|
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||||
|
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||||
|
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||||
|
console.error('');
|
||||||
|
console.error(
|
||||||
|
warn(
|
||||||
|
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||||
|
if (issueUrl) {
|
||||||
|
console.error(` Tracking: ${issueUrl}`);
|
||||||
|
}
|
||||||
|
console.error('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||||
|
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||||
|
const modelEntry = findModel(provider, currentModel);
|
||||||
|
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||||
|
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
||||||
|
console.error('');
|
||||||
|
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||||
|
if (replacementModel) {
|
||||||
|
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
||||||
|
} else {
|
||||||
|
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||||
|
}
|
||||||
|
if (issueUrl) {
|
||||||
|
console.error(` Tracking: ${issueUrl}`);
|
||||||
|
}
|
||||||
|
if (skipLocalAuth) {
|
||||||
|
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||||
|
} else {
|
||||||
|
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||||
|
}
|
||||||
|
console.error('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user