From ec84fd57dc9d8844a630cef4b1ed9825742b57b5 Mon Sep 17 00:00:00 2001 From: Nikolay Sulimov Date: Wed, 12 Aug 2026 20:12:34 +0500 Subject: [PATCH 1/3] feat(proxy): enhance proxy handling with noProxy rules and fallback mechanisms --- package-lock.json | 5 +- .../connectors/__tests__/desktop.test.ts | 13 ++ src/cli/commands/proxy/connectors/desktop.ts | 12 ++ src/cli/commands/proxy/index.ts | 15 ++ .../sso/proxy/plugins/sso-auth.plugin.ts | 42 +++- .../plugins/sso/proxy/proxy-errors.ts | 42 +++- .../plugins/sso/proxy/proxy-http-client.ts | 192 ++++++++++++++++-- 7 files changed, 301 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index fcc24503e..195943354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "codemie-claude-acp": "bin/codemie-claude-acp.js", "codemie-code": "bin/agent-executor.js", "codemie-codex": "bin/codemie-codex.js", + "codemie-copilot": "bin/codemie-copilot.js", "codemie-gemini": "bin/codemie-gemini.js", "codemie-kimi": "bin/codemie-kimi.js", "codemie-kimi-acp": "bin/codemie-kimi-acp.js", @@ -3692,7 +3693,7 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -9341,7 +9342,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/src/cli/commands/proxy/connectors/__tests__/desktop.test.ts b/src/cli/commands/proxy/connectors/__tests__/desktop.test.ts index f78f15b1c..cb2f6862e 100644 --- a/src/cli/commands/proxy/connectors/__tests__/desktop.test.ts +++ b/src/cli/commands/proxy/connectors/__tests__/desktop.test.ts @@ -121,6 +121,19 @@ describe('fetchClaudeModels', () => { .rejects.toThrow('Local proxy model discovery could not reach'); }); + it('falls back to preferred Claude ids when the proxy upstream returns 5xx', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: mkHeaders('application/json'), + json: async () => ({ data: [] }), + }) as unknown as typeof globalThis.fetch; + + const models = await fetchClaudeModels('http://127.0.0.1:4001', 'codemie-proxy'); + expect(models).toEqual(['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-haiku-4-5']); + }); + it('throws when response is not ok', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) as any; await expect(fetchClaudeModels('http://127.0.0.1:4001', 'codemie-proxy')) diff --git a/src/cli/commands/proxy/connectors/desktop.ts b/src/cli/commands/proxy/connectors/desktop.ts index d4bf5ac97..76802f9fa 100644 --- a/src/cli/commands/proxy/connectors/desktop.ts +++ b/src/cli/commands/proxy/connectors/desktop.ts @@ -85,6 +85,7 @@ export async function fetchClaudeModels(proxyUrl: string, gatewayKey: string): P headers: { Authorization: `Bearer ${gatewayKey}` }, }); if (!response.ok) { + const isUpstreamFailure = response.status >= 500 && response.status < 600; logger.warn( '[proxy] Gateway model discovery failed', ...sanitizeLogArgs({ @@ -92,8 +93,19 @@ export async function fetchClaudeModels(proxyUrl: string, gatewayKey: string): P status: response.status, statusText: response.statusText, inferenceGatewayBaseUrl: proxyUrl, + fallbackToPreferredModels: isUpstreamFailure, }) ); + if (isUpstreamFailure) { + logger.warn( + '[proxy] Falling back to the curated Claude Desktop model list because the upstream model catalog failed', + ...sanitizeLogArgs({ + endpoint, + preferredModels: [...PREFERRED_CLAUDE_MODELS], + }) + ); + return [...PREFERRED_CLAUDE_MODELS]; + } throw new ConfigurationError( response.status === 401 ? `Local proxy model discovery was rejected with 401 Unauthorized at ${endpoint}. ` + diff --git a/src/cli/commands/proxy/index.ts b/src/cli/commands/proxy/index.ts index 08e7098dd..a02ade039 100644 --- a/src/cli/commands/proxy/index.ts +++ b/src/cli/commands/proxy/index.ts @@ -17,6 +17,7 @@ import { readState, spawnDaemon, stopDaemon, + writeState, } from './daemon-manager.js'; import { writeDesktopConfig, getDesktopBaseDir, mapCanonicalToDesktop } from './connectors/desktop.js'; import { fetchManagedMcpServers } from './connectors/managed-mcp-remote.js'; @@ -301,6 +302,20 @@ export function createProxyCommand(): Command { if (health.healthy) { const label = health.level === 'deep' ? 'running, healthy (upstream OK)' : 'running, healthy'; console.log(`Status: ${chalk.green(label)}`); + + // If we explicitly verified upstream/auth and it is now healthy, + // clear any stale "last recorded issue" persisted by the watcher. + if (opts.deep && state.health === 'unhealthy') { + await writeState({ + ...state, + health: 'ok', + healthReason: undefined, + lastHealthyAt: new Date().toISOString(), + }); + state.health = 'ok'; + state.healthReason = undefined; + state.lastHealthyAt = new Date().toISOString(); + } } else { console.log(`Status: ${chalk.yellow('running but UNHEALTHY')}`); console.log(` Reason: ${health.reason ?? state.healthReason ?? 'unknown'}`); diff --git a/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts b/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts index d4a3668a1..61ef326ba 100644 --- a/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts @@ -10,6 +10,7 @@ import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; import { ProxyContext } from '../proxy-types.js'; import { SSOCredentials } from '../../../../core/types.js'; import { logger } from '../../../../../utils/logger.js'; +import { AuthenticationError } from '../proxy-errors.js'; export class SSOAuthPlugin implements ProxyPlugin { id = '@codemie/proxy-sso-auth'; @@ -40,10 +41,45 @@ class SSOAuthInterceptor implements ProxyInterceptor { constructor(private credentials: SSOCredentials) {} + private hasInvalidHeaderChars(value: string): boolean { + // RFC 7230 field-vchar / obs-text; disallow control chars except HTAB. + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if ((code <= 0x1f && code !== 0x09) || code === 0x7f) { + return true; + } + } + return false; + } + async onRequest(context: ProxyContext): Promise { - const cookieHeader = Object.entries(this.credentials.cookies) - .map(([key, value]) => `${key}=${value}`) - .join('; '); + const invalidCookieKeys: string[] = []; + const cookiePairs: string[] = []; + + for (const [key, value] of Object.entries(this.credentials.cookies)) { + const safeKey = String(key).trim(); + const safeValue = String(value); + if (!safeKey || this.hasInvalidHeaderChars(safeKey) || this.hasInvalidHeaderChars(safeValue)) { + invalidCookieKeys.push(safeKey || ''); + continue; + } + cookiePairs.push(`${safeKey}=${safeValue}`); + } + + if (invalidCookieKeys.length > 0) { + logger.warn(`[${this.name}] Ignoring invalid cookie entries`, { + invalidCookieCount: invalidCookieKeys.length, + invalidCookieNames: invalidCookieKeys, + }); + } + + if (cookiePairs.length === 0) { + throw new AuthenticationError( + 'Stored SSO session cookies are invalid. Run `codemie profile login` and reconnect the proxy.' + ); + } + + const cookieHeader = cookiePairs.join('; '); // Use lowercase 'cookie' to match Node.js HTTP header conventions context.headers['cookie'] = cookieHeader; diff --git a/src/providers/plugins/sso/proxy/proxy-errors.ts b/src/providers/plugins/sso/proxy/proxy-errors.ts index 59c757aaf..eadd18c23 100644 --- a/src/providers/plugins/sso/proxy/proxy-errors.ts +++ b/src/providers/plugins/sso/proxy/proxy-errors.ts @@ -79,9 +79,49 @@ export function normalizeError(error: unknown, context?: Record } if (error instanceof Error) { + const errorCode = (error as any).code as string | undefined; + const message = error.message || ''; + + // Node may throw synchronously for malformed header values (for example, + // corrupted/invalid cookie bytes). Treat this as an auth/session issue so + // users get a recovery action instead of a generic 500. + if ( + errorCode === 'ERR_INVALID_CHAR' || + errorCode === 'ERR_HTTP_INVALID_HEADER_VALUE' || + /header content|header value|cookie/i.test(message) + ) { + return new AuthenticationError( + 'Invalid authentication session data. Run `codemie profile login` and restart the proxy.', + { + originalError: message, + errorCode, + ...context, + } + ); + } + // Network errors if ('code' in error) { - const code = (error as any).code; + const code = errorCode; + + if ( + message.includes('self-signed certificate in certificate chain') || + message.includes('unable to verify the first certificate') || + message.includes('certificate has expired') || + code === 'SELF_SIGNED_CERT_IN_CHAIN' || + code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || + code === 'CERT_HAS_EXPIRED' + ) { + return new UpstreamError( + 502, + 'TLS validation failed when connecting to upstream. Check corporate proxy certificate chain or trust settings.', + { + originalError: message, + errorCode: code, + ...context, + } + ); + } if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ECONNRESET') { return new NetworkError(`Cannot connect to upstream server: ${error.message}`, { diff --git a/src/providers/plugins/sso/proxy/proxy-http-client.ts b/src/providers/plugins/sso/proxy/proxy-http-client.ts index acf609f91..6d511bfd1 100644 --- a/src/providers/plugins/sso/proxy/proxy-http-client.ts +++ b/src/providers/plugins/sso/proxy/proxy-http-client.ts @@ -9,6 +9,10 @@ import { pipeline } from 'stream/promises'; import https from 'https'; import http from 'http'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { isIP } from 'node:net'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { HttpProxyAgent } from 'http-proxy-agent'; import { NetworkError } from './proxy-errors.js'; @@ -25,6 +29,12 @@ export interface ForwardRequestOptions { body?: Buffer | string; // Accept Buffer or string } +type NoProxyRule = + | { kind: 'all' } + | { kind: 'host'; value: string } + | { kind: 'domain'; value: string } + | { kind: 'cidr'; base: number; maskBits: number }; + /** * Parse proxy URL from environment variables */ @@ -37,26 +47,147 @@ function getProxyUrl(protocol: 'http:' | 'https:'): string | undefined { return process.env.HTTP_PROXY || process.env.http_proxy; } +function splitRules(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(',') + .map(entry => entry.trim()) + .filter(Boolean); +} + +function parseIpv4(ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + const nums = parts.map(p => Number.parseInt(p, 10)); + if (nums.some(n => !Number.isFinite(n) || n < 0 || n > 255)) return null; + return (((nums[0] << 24) >>> 0) | ((nums[1] << 16) >>> 0) | ((nums[2] << 8) >>> 0) | nums[3]) >>> 0; +} + +function parseCidr(raw: string): { base: number; maskBits: number } | null { + const [ip, maskRaw] = raw.split('/'); + if (!ip || !maskRaw) return null; + const maskBits = Number.parseInt(maskRaw, 10); + if (!Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null; + const base = parseIpv4(ip); + if (base === null) return null; + return { base, maskBits }; +} + +function ipInCidr(ip: string, base: number, maskBits: number): boolean { + const value = parseIpv4(ip); + if (value === null) return false; + const mask = maskBits === 0 ? 0 : ((0xffffffff << (32 - maskBits)) >>> 0); + return (value & mask) === (base & mask); +} + +function parseNoProxyRules(values: string[]): NoProxyRule[] { + const rules: NoProxyRule[] = []; + + for (const raw of values) { + const value = raw.toLowerCase(); + if (!value) continue; + + if (value === '*') { + rules.push({ kind: 'all' }); + continue; + } + + const cidr = parseCidr(value); + if (cidr) { + rules.push({ kind: 'cidr', ...cidr }); + continue; + } + + if (value.startsWith('.')) { + rules.push({ kind: 'domain', value: value.slice(1) }); + continue; + } + + rules.push({ kind: 'host', value }); + } + + return rules; +} + +function readNpmNoProxyEntries(): string[] { + try { + const npmrcPath = join(homedir(), '.npmrc'); + const raw = readFileSync(npmrcPath, 'utf-8'); + const lines = raw.split(/\r?\n/); + + for (const lineRaw of lines) { + const line = lineRaw.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim().toLowerCase(); + const value = line.slice(eq + 1).trim(); + if (key === 'noproxy' || key === 'no-proxy') { + return splitRules(value); + } + } + } catch { + // No user npmrc or unreadable file — ignore. + } + + return []; +} + +function shouldBypassProxy(hostname: string, rules: NoProxyRule[]): boolean { + const host = hostname.toLowerCase(); + const ipVersion = isIP(host); + + for (const rule of rules) { + if (rule.kind === 'all') { + return true; + } + + if (rule.kind === 'host') { + if (host === rule.value) return true; + continue; + } + + if (rule.kind === 'domain') { + if (host === rule.value || host.endsWith(`.${rule.value}`)) return true; + continue; + } + + if (rule.kind === 'cidr' && ipVersion === 4) { + if (ipInCidr(host, rule.base, rule.maskBits)) return true; + } + } + + return false; +} + /** * Simple streaming HTTP client for proxy forwarding */ export class ProxyHTTPClient { - private httpsAgent: https.Agent; - private httpAgent: http.Agent; + private directHttpsAgent: https.Agent; + private directHttpAgent: http.Agent; + private proxyHttpsAgent: https.Agent | undefined; + private proxyHttpAgent: http.Agent | undefined; private timeout: number; + private rejectUnauthorized: boolean; + private noProxyRules: NoProxyRule[]; constructor(options: HTTPClientOptions = {}) { // Use provided timeout or 0 for unlimited (AI requests can be very long) this.timeout = options.timeout || 0; + this.rejectUnauthorized = options.rejectUnauthorized ?? false; // Check for proxy configuration from environment variables const httpsProxyUrl = getProxyUrl('https:'); const httpProxyUrl = getProxyUrl('http:'); + const envNoProxyEntries = splitRules(process.env.NO_PROXY || process.env.no_proxy); + const npmNoProxyEntries = readNpmNoProxyEntries(); + this.noProxyRules = parseNoProxyRules([...envNoProxyEntries, ...npmNoProxyEntries]); // Connection pooling with keep-alive // NO timeout on agent - we handle it at request level const baseAgentOptions = { - rejectUnauthorized: options.rejectUnauthorized ?? false, + rejectUnauthorized: this.rejectUnauthorized, keepAlive: true, maxSockets: 50 }; @@ -64,24 +195,54 @@ export class ProxyHTTPClient { // Create HTTPS agent (with proxy support if configured) if (httpsProxyUrl) { logger.debug('[proxy-http-client] Using HTTPS proxy:', httpsProxyUrl); - this.httpsAgent = new HttpsProxyAgent(httpsProxyUrl, baseAgentOptions); - } else { - this.httpsAgent = new https.Agent(baseAgentOptions); + this.proxyHttpsAgent = new HttpsProxyAgent(httpsProxyUrl, baseAgentOptions); } + this.directHttpsAgent = new https.Agent(baseAgentOptions); // Create HTTP agent (with proxy support if configured) if (httpProxyUrl) { logger.debug('[proxy-http-client] Using HTTP proxy:', httpProxyUrl); - this.httpAgent = new HttpProxyAgent(httpProxyUrl, { + this.proxyHttpAgent = new HttpProxyAgent(httpProxyUrl, { keepAlive: true, maxSockets: 50 }); - } else { - this.httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 50 + } + this.directHttpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 50 + }); + + logger.debug('[proxy-http-client] NO_PROXY rules loaded', { + envRules: envNoProxyEntries, + npmRules: npmNoProxyEntries, + totalRules: this.noProxyRules.length, + }); + } + + private getAgentForUrl(url: URL): http.Agent { + const bypass = shouldBypassProxy(url.hostname, this.noProxyRules); + + if (url.protocol === 'https:') { + if (!bypass && this.proxyHttpsAgent) { + logger.debug('[proxy-http-client] Routing HTTPS request via proxy', { host: url.hostname }); + return this.proxyHttpsAgent; + } + logger.debug('[proxy-http-client] Routing HTTPS request directly (no_proxy match or proxy disabled)', { + host: url.hostname, + bypass, }); + return this.directHttpsAgent; } + + if (!bypass && this.proxyHttpAgent) { + logger.debug('[proxy-http-client] Routing HTTP request via proxy', { host: url.hostname }); + return this.proxyHttpAgent; + } + logger.debug('[proxy-http-client] Routing HTTP request directly (no_proxy match or proxy disabled)', { + host: url.hostname, + bypass, + }); + return this.directHttpAgent; } /** @@ -93,7 +254,7 @@ export class ProxyHTTPClient { options: ForwardRequestOptions ): Promise { const protocol = url.protocol === 'https:' ? https : http; - const agent = url.protocol === 'https:' ? this.httpsAgent : this.httpAgent; + const agent = this.getAgentForUrl(url); logger.debug('[http-client] Forwarding request to upstream', { url: url.toString(), @@ -109,6 +270,7 @@ export class ProxyHTTPClient { method: options.method, headers: options.headers, agent, + ...(url.protocol === 'https:' ? { rejectUnauthorized: this.rejectUnauthorized } : {}), // Only set timeout if explicitly configured (0 = unlimited) timeout: Math.max(this.timeout, 0) }; @@ -277,7 +439,9 @@ export class ProxyHTTPClient { * Close HTTP client and cleanup agents */ close(): void { - this.httpsAgent.destroy(); - this.httpAgent.destroy(); + this.directHttpsAgent.destroy(); + this.directHttpAgent.destroy(); + this.proxyHttpsAgent?.destroy(); + this.proxyHttpAgent?.destroy(); } } From 30c5bc84945d8631ddf427e978ecf2d4f1d5da73 Mon Sep 17 00:00:00 2001 From: Nikolay Sulimov Date: Mon, 17 Aug 2026 15:28:00 +0500 Subject: [PATCH 2/3] feat(proxy): enhance VS Code model handling with gateway integration and fallback logic --- .../commands/proxy/__tests__/index.test.ts | 3 + .../proxy/connectors/__tests__/vscode.test.ts | 6 +- .../proxy/connectors/gateway-models.ts | 77 ++++++++ src/cli/commands/proxy/connectors/vscode.ts | 182 ++++++++++++++---- src/cli/commands/proxy/index.ts | 6 +- 5 files changed, 234 insertions(+), 40 deletions(-) create mode 100644 src/cli/commands/proxy/connectors/gateway-models.ts diff --git a/src/cli/commands/proxy/__tests__/index.test.ts b/src/cli/commands/proxy/__tests__/index.test.ts index f9790d212..6f4382b6d 100644 --- a/src/cli/commands/proxy/__tests__/index.test.ts +++ b/src/cli/commands/proxy/__tests__/index.test.ts @@ -400,6 +400,7 @@ describe('proxy connect vscode', () => { vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/mock/chatLanguageModels.json', requiresSecretConfiguration: false, + modelIds: ['gpt-4.1'], }); }); @@ -450,6 +451,7 @@ describe('proxy connect vscode', () => { expect(daemonOptions).not.toHaveProperty('model'); expect(writeVsCodeLanguageModelsConfig).toHaveBeenCalledWith( 'http://127.0.0.1:4001', + 'local-key', false ); }); @@ -491,6 +493,7 @@ describe('proxy connect vscode', () => { expect(spawnDaemon).not.toHaveBeenCalled(); expect(writeVsCodeLanguageModelsConfig).toHaveBeenCalledWith( 'http://127.0.0.1:4001', + 'local-key', false ); }); diff --git a/src/cli/commands/proxy/connectors/__tests__/vscode.test.ts b/src/cli/commands/proxy/connectors/__tests__/vscode.test.ts index 42afa7407..a0e4bfb56 100644 --- a/src/cli/commands/proxy/connectors/__tests__/vscode.test.ts +++ b/src/cli/commands/proxy/connectors/__tests__/vscode.test.ts @@ -74,7 +74,11 @@ describe('writeVsCodeLanguageModelsConfigAtPath', () => { const provider = providers[0]; const models = provider.models as Array>; - expect(result).toEqual({ configPath, requiresSecretConfiguration: true }); + expect(result).toEqual({ + configPath, + requiresSecretConfiguration: true, + modelIds: [...EXPECTED_MODEL_IDS], + }); expect(provider).toMatchObject({ name: 'CodeMie', vendor: 'customendpoint', diff --git a/src/cli/commands/proxy/connectors/gateway-models.ts b/src/cli/commands/proxy/connectors/gateway-models.ts new file mode 100644 index 000000000..084f9024e --- /dev/null +++ b/src/cli/commands/proxy/connectors/gateway-models.ts @@ -0,0 +1,77 @@ +import { ConfigurationError } from '@/utils/errors.js'; +import { logger } from '@/utils/logger.js'; +import { sanitizeLogArgs } from '@/utils/security.js'; + +/** SSO-backed catalog endpoint; `include_all` returns every model the tenant may use, not just the defaults. */ +const MODELS_PATH = '/v1/llm_models?include_all=true'; + +interface ModelsListResponse { + data?: Array<{ id?: string }>; +} + +interface CodeMieLlmModel { + id?: string; + base_name?: string; + deployment_name?: string; +} + +function extractModelIds(json: ModelsListResponse | CodeMieLlmModel[]): string[] { + const ids = Array.isArray(json) + ? json.map((model) => model.id || model.base_name || model.deployment_name) + : (json.data ?? []).map((model) => model.id); + return [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0))]; +} + +/** + * Fetch every model ID the gateway serves for the active profile. + * + * Unlike the Claude Desktop path this applies no family filter — callers decide + * what to expose. Always throws {@link ConfigurationError} on failure so callers + * can pick their own fallback instead of silently receiving an empty catalog. + */ +export async function fetchGatewayModelIds( + proxyUrl: string, + gatewayKey: string +): Promise { + const endpoint = new URL(MODELS_PATH, proxyUrl).toString(); + + let response: Response; + try { + response = await fetch(endpoint, { + headers: { Authorization: `Bearer ${gatewayKey}` }, + }); + } catch (error) { + throw new ConfigurationError( + `Local proxy model discovery could not reach ${endpoint}. ` + + `Reason: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!response.ok) { + throw new ConfigurationError( + response.status === 401 + ? `Local proxy model discovery was rejected with 401 Unauthorized at ${endpoint}. ` + + 'The local gateway key was not accepted by the proxy or was forwarded upstream incorrectly.' + : `Local proxy model discovery failed at ${endpoint}: ${response.status} ${response.statusText}` + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + throw new ConfigurationError( + `Local proxy model discovery received an unexpected response (${contentType || 'no content-type'}) from ${endpoint}. ` + + 'Your SSO session may have expired — run `codemie proxy stop && codemie profile login` to re-authenticate.' + ); + } + + const ids = extractModelIds(await response.json() as ModelsListResponse | CodeMieLlmModel[]); + logger.info( + '[proxy] Gateway model discovery completed', + ...sanitizeLogArgs({ + endpoint, + modelCount: ids.length, + models: ids, + }) + ); + return ids; +} diff --git a/src/cli/commands/proxy/connectors/vscode.ts b/src/cli/commands/proxy/connectors/vscode.ts index 01f496465..4ccf71594 100644 --- a/src/cli/commands/proxy/connectors/vscode.ts +++ b/src/cli/commands/proxy/connectors/vscode.ts @@ -3,14 +3,27 @@ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promis import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { ConfigurationError } from '@/utils/errors.js'; +import { logger } from '@/utils/logger.js'; +import { sanitizeLogArgs } from '@/utils/security.js'; +import { fetchGatewayModelIds } from './gateway-models.js'; import { VS_CODE_SUPPORTED_MODELS, type VsCodeApiType, + type VsCodeModelDefinition, type VsCodeReasoningEffort, } from './vscode-models.js'; const SECRET_REFERENCE_PATTERN = /^\$\{input:chat\.lm\.secret\.[^}]+\}$/; +/** Capabilities assumed for a gateway model the curated catalog does not describe. */ +const UNCATALOGED_MODEL_DEFAULTS: Omit = { + apiType: 'chat-completions', + vision: false, + thinking: false, + maxInputTokens: 128000, + maxOutputTokens: 16384, +}; + interface VsCodeLanguageModelProvider { [key: string]: unknown; name?: string; @@ -46,6 +59,8 @@ interface VsCodeManagedModel { export interface WriteVsCodeConfigResult { configPath: string; requiresSecretConfiguration: boolean; + /** Model IDs written under the CodeMie provider, in the order VS Code will see them. */ + modelIds: string[]; } function isRecord(value: unknown): value is Record { @@ -104,41 +119,95 @@ function getApiPath(apiType: VsCodeApiType): string { return '/v1/chat/completions'; } -function buildManagedModels(proxyUrl: string): VsCodeManagedModel[] { - return VS_CODE_SUPPORTED_MODELS.map(definition => { - const model: VsCodeManagedModel = { - id: definition.id, - name: definition.id, - url: new URL(getApiPath(definition.apiType), proxyUrl).toString(), - apiType: definition.apiType, - toolCalling: true, - vision: definition.vision, - streaming: true, - thinking: definition.thinking, - maxInputTokens: definition.maxInputTokens, - maxOutputTokens: definition.maxOutputTokens, - }; - - if (definition.adaptiveThinking) model.adaptiveThinking = true; - if (definition.zeroDataRetentionEnabled !== undefined) { - model.zeroDataRetentionEnabled = definition.zeroDataRetentionEnabled; - } - if (definition.modelOptions) model.modelOptions = definition.modelOptions; - if (definition.requestHeaders) model.requestHeaders = definition.requestHeaders; - if (definition.supportsReasoningEffort) { - model.supportsReasoningEffort = definition.supportsReasoningEffort; - } - if (definition.reasoningEffortFormat) { - model.reasoningEffortFormat = definition.reasoningEffortFormat; - } +const CATALOG_BY_ID = new Map(VS_CODE_SUPPORTED_MODELS.map(definition => [definition.id, definition])); +const CATALOG_ORDER = new Map(VS_CODE_SUPPORTED_MODELS.map((definition, index) => [definition.id, index])); + +/** + * Match a gateway model ID against the curated catalog, tolerating the suffixes + * the gateway adds to its registrations (`-YYYYMMDD` snapshots, `-vertex` + * deployments). Returns undefined when the catalog says nothing about the model, + * which is not an error: the model is still exposed, with default capabilities. + */ +export function resolveModelDefinition(id: string): VsCodeModelDefinition | undefined { + const stripVertex = (value: string): string => value.replace(/-vertex$/i, ''); + const stripSnapshot = (value: string): string => value.replace(/-\d{6,10}$/, ''); + const candidates = new Set([ + id, + stripVertex(id), + stripSnapshot(id), + stripSnapshot(stripVertex(id)), + ]); + + for (const candidate of candidates) { + const definition = CATALOG_BY_ID.get(candidate); + if (definition) return definition; + } + return undefined; +} - return model; - }); +/** Cataloged models first (in catalog order) so the familiar picker ordering survives. */ +function orderModelIds(availableIds: readonly string[]): string[] { + const known: Array<{ id: string; rank: number }> = []; + const unknown: string[] = []; + + for (const id of new Set(availableIds)) { + const rank = CATALOG_ORDER.get(resolveModelDefinition(id)?.id ?? ''); + if (rank === undefined) unknown.push(id); + else known.push({ id, rank }); + } + + known.sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id)); + unknown.sort((a, b) => a.localeCompare(b)); + return [...known.map(entry => entry.id), ...unknown]; +} + +function toManagedModel(proxyUrl: string, id: string): VsCodeManagedModel { + const definition = resolveModelDefinition(id) ?? UNCATALOGED_MODEL_DEFAULTS; + const model: VsCodeManagedModel = { + id, + name: id, + url: new URL(getApiPath(definition.apiType), proxyUrl).toString(), + apiType: definition.apiType, + toolCalling: true, + vision: definition.vision, + streaming: true, + thinking: definition.thinking, + maxInputTokens: definition.maxInputTokens, + maxOutputTokens: definition.maxOutputTokens, + }; + + if (definition.adaptiveThinking) model.adaptiveThinking = true; + if (definition.zeroDataRetentionEnabled !== undefined) { + model.zeroDataRetentionEnabled = definition.zeroDataRetentionEnabled; + } + if (definition.modelOptions) model.modelOptions = definition.modelOptions; + if (definition.requestHeaders) model.requestHeaders = definition.requestHeaders; + if (definition.supportsReasoningEffort) { + model.supportsReasoningEffort = definition.supportsReasoningEffort; + } + if (definition.reasoningEffortFormat) { + model.reasoningEffortFormat = definition.reasoningEffortFormat; + } + + return model; +} + +/** + * Build the provider's model list. `availableIds` is what the gateway actually + * serves; omitting it falls back to the curated catalog, which is only correct + * when discovery could not run. + */ +function buildManagedModels(proxyUrl: string, availableIds?: readonly string[]): VsCodeManagedModel[] { + const ids = availableIds + ? orderModelIds(availableIds) + : VS_CODE_SUPPORTED_MODELS.map(definition => definition.id); + return ids.map(id => toManagedModel(proxyUrl, id)); } function mergeManagedProviders( providers: VsCodeLanguageModelProvider[], - proxyUrl: string + proxyUrl: string, + availableIds?: readonly string[] ): { provider: VsCodeLanguageModelProvider; requiresSecretConfiguration: boolean } { const existingProvider = Object.assign({}, ...providers); const existingSettings = Object.assign( @@ -154,7 +223,7 @@ function mergeManagedProviders( name: 'CodeMie', vendor: 'customendpoint', apiType: 'chat-completions', - models: buildManagedModels(proxyUrl), + models: buildManagedModels(proxyUrl, availableIds), }; // VS Code owns effort selections. Preserve them instead of racing with the editor. @@ -223,19 +292,47 @@ async function writeAtomically(configPath: string, content: string): Promise { + try { + const ids = await fetchGatewayModelIds(proxyUrl, gatewayKey); + if (ids.length > 0) return ids; + logger.warn('[proxy] Gateway returned no models — falling back to the curated VS Code catalog'); + } catch (error) { + logger.warn( + '[proxy] Gateway model discovery failed — falling back to the curated VS Code catalog', + ...sanitizeLogArgs({ + error: error instanceof Error ? error.message : String(error), + }) + ); + } + return undefined; +} + export async function writeVsCodeLanguageModelsConfig( proxyUrl: string, + gatewayKey: string, insiders = false ): Promise { + const configPath = getVsCodeLanguageModelsPath(insiders); return writeVsCodeLanguageModelsConfigAtPath( - getVsCodeLanguageModelsPath(insiders), - proxyUrl + configPath, + proxyUrl, + await discoverModelIds(proxyUrl, gatewayKey) ); } export async function writeVsCodeLanguageModelsConfigAtPath( configPath: string, - proxyUrl: string + proxyUrl: string, + availableModelIds?: readonly string[] ): Promise { const providers = await readProviders(configPath); const managedProviderIndexes = providers @@ -245,7 +342,7 @@ export async function writeVsCodeLanguageModelsConfigAtPath( .map(index => providers[index]) .filter(isManagedProvider); const { provider: managedProvider, requiresSecretConfiguration } = - mergeManagedProviders(managedProviders, proxyUrl); + mergeManagedProviders(managedProviders, proxyUrl, availableModelIds); const firstManagedProviderIndex = managedProviderIndexes[0] ?? providers.length; const managedProviderIndexSet = new Set(managedProviderIndexes); const reconciledProviders = providers.flatMap((provider, index) => { @@ -264,5 +361,18 @@ export async function writeVsCodeLanguageModelsConfigAtPath( ); } - return { configPath, requiresSecretConfiguration }; + const modelIds = (managedProvider.models as VsCodeManagedModel[]).map(model => model.id); + logger.info( + '[proxy] VS Code managed model catalog resolved', + ...sanitizeLogArgs({ + configPath, + proxyUrl, + discoveredModelCount: availableModelIds?.length ?? 0, + usedCuratedFallback: availableModelIds === undefined, + writtenModelCount: modelIds.length, + writtenModels: modelIds, + }) + ); + + return { configPath, requiresSecretConfiguration, modelIds }; } diff --git a/src/cli/commands/proxy/index.ts b/src/cli/commands/proxy/index.ts index 0ba615700..5806d3941 100644 --- a/src/cli/commands/proxy/index.ts +++ b/src/cli/commands/proxy/index.ts @@ -22,7 +22,6 @@ import { import { writeDesktopConfig, describeManagedSettingsOverride, getDesktopBaseDir, mapCanonicalToDesktop, summarizeManagedOauthShapes } from './connectors/desktop.js'; import { fetchManagedMcpServers } from './connectors/managed-mcp-remote.js'; import { writeVsCodeLanguageModelsConfig } from './connectors/vscode.js'; -import { VS_CODE_SUPPORTED_MODELS } from './connectors/vscode-models.js'; import { checkProxyHealth } from './health-check.js'; import { printDesktopInspection } from './inspect-desktop.js'; @@ -630,6 +629,7 @@ export function createProxyCommand(): Command { const result = await writeVsCodeLanguageModelsConfig( state!.url, + state!.gatewayKey, Boolean(opts.insiders) ); logger.info( @@ -639,7 +639,7 @@ export function createProxyCommand(): Command { gatewayUrl: state!.url, profile: state!.profile, project: state!.project, - modelCount: VS_CODE_SUPPORTED_MODELS.length, + modelCount: result.modelIds.length, clientType: state!.clientType, requiresSecretConfiguration: result.requiresSecretConfiguration, }) @@ -649,7 +649,7 @@ export function createProxyCommand(): Command { if (verbose) { console.log(` Config: ${result.configPath}`); console.log(` Gateway: ${state!.url}`); - console.log(` Models: ${VS_CODE_SUPPORTED_MODELS.length}`); + console.log(` Models: ${result.modelIds.length}`); console.log(` Project: ${config.codeMieProject || '(not configured)'}`); } From 638df959226433ea4b809529167320b453c2df63 Mon Sep 17 00:00:00 2001 From: Nikolay Sulimov Date: Tue, 18 Aug 2026 14:28:09 +0500 Subject: [PATCH 3/3] feat(proxy): enhance noProxy rule handling with host and port support --- .../sso/proxy/proxy-http-client.test.ts | 125 ++++++++++++++++++ .../plugins/sso/proxy/proxy-http-client.ts | 60 +++++++-- 2 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 src/providers/plugins/sso/proxy/proxy-http-client.test.ts diff --git a/src/providers/plugins/sso/proxy/proxy-http-client.test.ts b/src/providers/plugins/sso/proxy/proxy-http-client.test.ts new file mode 100644 index 000000000..7ea614d10 --- /dev/null +++ b/src/providers/plugins/sso/proxy/proxy-http-client.test.ts @@ -0,0 +1,125 @@ +/** + * ProxyHTTPClient tests + * @group unit + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(() => ''), +})); + +import { ProxyHTTPClient } from './proxy-http-client.js'; + +function getAgent(client: ProxyHTTPClient, url: string): object { + return (client as unknown as { getAgentForUrl(target: URL): object }).getAgentForUrl(new URL(url)); +} + +function getRoutingKind(client: ProxyHTTPClient, url: string): 'direct' | 'proxy' { + const internal = client as unknown as { + getAgentForUrl(target: URL): object; + directHttpAgent: object; + directHttpsAgent: object; + }; + const agent = internal.getAgentForUrl(new URL(url)); + return agent === internal.directHttpAgent || agent === internal.directHttpsAgent ? 'direct' : 'proxy'; +} + +describe('ProxyHTTPClient NO_PROXY routing', () => { + const originalProxyEnv = { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + NO_PROXY: process.env.NO_PROXY, + http_proxy: process.env.http_proxy, + https_proxy: process.env.https_proxy, + no_proxy: process.env.no_proxy, + }; + + afterEach(() => { + for (const [key, value] of Object.entries(originalProxyEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + function createClient(noProxy: string): ProxyHTTPClient { + process.env.HTTP_PROXY = 'http://proxy.example.test:8080'; + process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'; + process.env.NO_PROXY = noProxy; + process.env.http_proxy = process.env.HTTP_PROXY; + process.env.https_proxy = process.env.HTTPS_PROXY; + process.env.no_proxy = process.env.NO_PROXY; + const client = new ProxyHTTPClient(); + expect((client as unknown as { proxyHttpAgent?: object }).proxyHttpAgent).toBeDefined(); + expect((client as unknown as { proxyHttpsAgent?: object }).proxyHttpsAgent).toBeDefined(); + return client; + } + + it('bypasses the proxy for an exact host on any port', () => { + const client = createClient('api.example.com'); + + expect(getAgent(client, 'https://api.example.com:8443/v1')).toBe( + getAgent(client, 'https://api.example.com:9443/v1') + ); + + client.close(); + }); + + it('bypasses the proxy for a domain and its subdomains', () => { + const client = createClient('.example.com'); + + expect(getAgent(client, 'https://api.example.com/v1')).toBe( + getAgent(client, 'https://nested.api.example.com/v1') + ); + expect(getRoutingKind(client, 'https://external.example.net/v1')).toBe('proxy'); + + client.close(); + }); + + it('bypasses the proxy for an IPv4 address inside a CIDR range', () => { + const client = createClient('10.20.0.0/16'); + + expect(getAgent(client, 'http://10.20.15.7/v1')).toBe( + getAgent(client, 'http://10.20.99.8/v1') + ); + expect(getRoutingKind(client, 'http://10.21.15.7/v1')).toBe('proxy'); + + client.close(); + }); + + it('bypasses the proxy for every host when wildcard is configured', () => { + const client = createClient('*'); + + expect(getAgent(client, 'http://public.example.test')).toBe( + getAgent(client, 'http://private.example.test') + ); + + client.close(); + }); + + it('bypasses the proxy only for the configured host and port', () => { + const client = createClient('api.example.com:8080'); + + expect(getRoutingKind(client, 'http://api.example.com:8080/v1')).toBe('direct'); + expect(getRoutingKind(client, 'http://api.example.com:8081/v1')).toBe('proxy'); + expect(getAgent(client, 'http://api.example.com:8081/v1')).toBe( + getAgent(client, 'http://other.example.com:8081/v1') + ); + + client.close(); + }); + + it('uses the implicit protocol port for a port-specific rule', () => { + const client = createClient('api.example.com:443'); + + expect(getRoutingKind(client, 'https://api.example.com/v1')).toBe('direct'); + expect(getRoutingKind(client, 'https://api.example.com:8443/v1')).toBe('proxy'); + expect(getAgent(client, 'https://api.example.com:8443/v1')).toBe( + getAgent(client, 'https://other.example.com:8443/v1') + ); + + client.close(); + }); +}); diff --git a/src/providers/plugins/sso/proxy/proxy-http-client.ts b/src/providers/plugins/sso/proxy/proxy-http-client.ts index 6d511bfd1..875285ea1 100644 --- a/src/providers/plugins/sso/proxy/proxy-http-client.ts +++ b/src/providers/plugins/sso/proxy/proxy-http-client.ts @@ -31,8 +31,8 @@ export interface ForwardRequestOptions { type NoProxyRule = | { kind: 'all' } - | { kind: 'host'; value: string } - | { kind: 'domain'; value: string } + | { kind: 'host'; value: string; port?: number } + | { kind: 'domain'; value: string; port?: number } | { kind: 'cidr'; base: number; maskBits: number }; /** @@ -80,6 +80,32 @@ function ipInCidr(ip: string, base: number, maskBits: number): boolean { return (value & mask) === (base & mask); } +function parseHostPort(raw: string): { host: string; port?: number } { + if (raw.startsWith('[')) { + const closingBracket = raw.indexOf(']'); + if (closingBracket > 0 && raw[closingBracket + 1] === ':') { + const portRaw = raw.slice(closingBracket + 2); + const port = Number.parseInt(portRaw, 10); + if (/^\d+$/.test(portRaw) && port >= 1 && port <= 65535) { + return { host: raw.slice(1, closingBracket), port }; + } + } + return { host: raw }; + } + + const separator = raw.lastIndexOf(':'); + if (separator > 0 && raw.indexOf(':') === separator) { + const host = raw.slice(0, separator); + const portRaw = raw.slice(separator + 1); + const port = Number.parseInt(portRaw, 10); + if (/^\d+$/.test(portRaw) && port >= 1 && port <= 65535) { + return { host, port }; + } + } + + return { host: raw }; +} + function parseNoProxyRules(values: string[]): NoProxyRule[] { const rules: NoProxyRule[] = []; @@ -98,12 +124,13 @@ function parseNoProxyRules(values: string[]): NoProxyRule[] { continue; } - if (value.startsWith('.')) { - rules.push({ kind: 'domain', value: value.slice(1) }); + const { host, port } = parseHostPort(value); + if (host.startsWith('.')) { + rules.push({ kind: 'domain', value: host.slice(1), port }); continue; } - rules.push({ kind: 'host', value }); + rules.push({ kind: 'host', value: host, port }); } return rules; @@ -133,9 +160,12 @@ function readNpmNoProxyEntries(): string[] { return []; } -function shouldBypassProxy(hostname: string, rules: NoProxyRule[]): boolean { +function matchesPort(rule: { port?: number }, port: number): boolean { + return rule.port === undefined || rule.port === port; +} + +function shouldBypassProxy(hostname: string, port: number, rules: NoProxyRule[]): boolean { const host = hostname.toLowerCase(); - const ipVersion = isIP(host); for (const rule of rules) { if (rule.kind === 'all') { @@ -143,16 +173,17 @@ function shouldBypassProxy(hostname: string, rules: NoProxyRule[]): boolean { } if (rule.kind === 'host') { - if (host === rule.value) return true; + if (host === rule.value && matchesPort(rule, port)) return true; continue; } if (rule.kind === 'domain') { - if (host === rule.value || host.endsWith(`.${rule.value}`)) return true; + const matchesDomain = host === rule.value || host.endsWith(`.${rule.value}`); + if (matchesDomain && matchesPort(rule, port)) return true; continue; } - if (rule.kind === 'cidr' && ipVersion === 4) { + if (rule.kind === 'cidr' && isIP(host) === 4) { if (ipInCidr(host, rule.base, rule.maskBits)) return true; } } @@ -220,26 +251,29 @@ export class ProxyHTTPClient { } private getAgentForUrl(url: URL): http.Agent { - const bypass = shouldBypassProxy(url.hostname, this.noProxyRules); + const port = Number.parseInt(url.port, 10) || (url.protocol === 'https:' ? 443 : 80); + const bypass = shouldBypassProxy(url.hostname, port, this.noProxyRules); if (url.protocol === 'https:') { if (!bypass && this.proxyHttpsAgent) { - logger.debug('[proxy-http-client] Routing HTTPS request via proxy', { host: url.hostname }); + logger.debug('[proxy-http-client] Routing HTTPS request via proxy', { host: url.hostname, port }); return this.proxyHttpsAgent; } logger.debug('[proxy-http-client] Routing HTTPS request directly (no_proxy match or proxy disabled)', { host: url.hostname, + port, bypass, }); return this.directHttpsAgent; } if (!bypass && this.proxyHttpAgent) { - logger.debug('[proxy-http-client] Routing HTTP request via proxy', { host: url.hostname }); + logger.debug('[proxy-http-client] Routing HTTP request via proxy', { host: url.hostname, port }); return this.proxyHttpAgent; } logger.debug('[proxy-http-client] Routing HTTP request directly (no_proxy match or proxy disabled)', { host: url.hostname, + port, bypass, }); return this.directHttpAgent;