mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
* feat: add OpenAI o3 model support with enhanced tool calling - Add o3 and o3-mini model definitions with reasoning effort support - Implement o3-compatible strict schema validation for MCP tools - Add comprehensive o3 schema processing with DRY improvements - Extract reusable schema processing functions for maintainability - Add 15+ test cases validating o3 strict mode requirements - Fix schema composition keyword handling with loop-based approach - Ensure ALL object schemas have complete required arrays for o3 - Support tool calling with proper o3 schema transformations This enables OpenAI o3 models to work properly with MCP tool calling while improving code organization and test coverage. Signed-off-by: Luke Galea <luke@ideaforge.org> * Remove redundant reference in HtmlArtifactsPopup.tsx * refactor: move filterProperties to mcp-schema, fix tests --------- Signed-off-by: Luke Galea <luke@ideaforge.org> Co-authored-by: one <wangan.cs@gmail.com> Co-authored-by: suyao <sy20010504@gmail.com>
287 lines
8.7 KiB
TypeScript
287 lines
8.7 KiB
TypeScript
import { loggerService } from '@logger'
|
|
import { defaultByPassRules } from '@shared/config/constant'
|
|
import axios from 'axios'
|
|
import { app, ProxyConfig, session } from 'electron'
|
|
import { socksDispatcher } from 'fetch-socks'
|
|
import http from 'http'
|
|
import https from 'https'
|
|
import { getSystemProxy } from 'os-proxy-config'
|
|
import { ProxyAgent } from 'proxy-agent'
|
|
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
|
|
|
|
const logger = loggerService.withContext('ProxyManager')
|
|
let byPassRules = defaultByPassRules.split(',')
|
|
|
|
const isByPass = (hostname: string) => {
|
|
return byPassRules.includes(hostname)
|
|
}
|
|
|
|
class SelectiveDispatcher extends Dispatcher {
|
|
private proxyDispatcher: Dispatcher
|
|
private directDispatcher: Dispatcher
|
|
|
|
constructor(proxyDispatcher: Dispatcher, directDispatcher: Dispatcher) {
|
|
super()
|
|
this.proxyDispatcher = proxyDispatcher
|
|
this.directDispatcher = directDispatcher
|
|
}
|
|
|
|
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
|
|
if (opts.origin) {
|
|
const url = new URL(opts.origin)
|
|
// 检查是否为 localhost 或本地地址
|
|
if (isByPass(url.hostname)) {
|
|
return this.directDispatcher.dispatch(opts, handler)
|
|
}
|
|
}
|
|
|
|
return this.proxyDispatcher.dispatch(opts, handler)
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
try {
|
|
await this.proxyDispatcher.close()
|
|
} catch (error) {
|
|
logger.error('Failed to close dispatcher:', error as Error)
|
|
this.proxyDispatcher.destroy()
|
|
}
|
|
}
|
|
|
|
async destroy(): Promise<void> {
|
|
try {
|
|
await this.proxyDispatcher.destroy()
|
|
} catch (error) {
|
|
logger.error('Failed to destroy dispatcher:', error as Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
export class ProxyManager {
|
|
private config: ProxyConfig = { mode: 'direct' }
|
|
private systemProxyInterval: NodeJS.Timeout | null = null
|
|
private isSettingProxy = false
|
|
|
|
private proxyDispatcher: Dispatcher | null = null
|
|
private proxyAgent: ProxyAgent | null = null
|
|
|
|
private originalGlobalDispatcher: Dispatcher
|
|
private originalSocksDispatcher: Dispatcher
|
|
// for http and https
|
|
private originalHttpGet: typeof http.get
|
|
private originalHttpRequest: typeof http.request
|
|
private originalHttpsGet: typeof https.get
|
|
private originalHttpsRequest: typeof https.request
|
|
|
|
constructor() {
|
|
this.originalGlobalDispatcher = getGlobalDispatcher()
|
|
this.originalSocksDispatcher = global[Symbol.for('undici.globalDispatcher.1')]
|
|
this.originalHttpGet = http.get
|
|
this.originalHttpRequest = http.request
|
|
this.originalHttpsGet = https.get
|
|
this.originalHttpsRequest = https.request
|
|
}
|
|
|
|
private async monitorSystemProxy(): Promise<void> {
|
|
// Clear any existing interval first
|
|
this.clearSystemProxyMonitor()
|
|
// Set new interval
|
|
this.systemProxyInterval = setInterval(async () => {
|
|
const currentProxy = await getSystemProxy()
|
|
if (currentProxy && currentProxy.proxyUrl.toLowerCase() === this.config?.proxyRules) {
|
|
return
|
|
}
|
|
|
|
await this.configureProxy({
|
|
mode: 'system',
|
|
proxyRules: currentProxy?.proxyUrl.toLowerCase(),
|
|
proxyBypassRules: this.config.proxyBypassRules
|
|
})
|
|
}, 1000 * 60)
|
|
}
|
|
|
|
private clearSystemProxyMonitor(): void {
|
|
if (this.systemProxyInterval) {
|
|
clearInterval(this.systemProxyInterval)
|
|
this.systemProxyInterval = null
|
|
}
|
|
}
|
|
|
|
async configureProxy(config: ProxyConfig): Promise<void> {
|
|
logger.info(`configureProxy: ${config?.mode} ${config?.proxyRules} ${config?.proxyBypassRules}`)
|
|
|
|
if (this.isSettingProxy) {
|
|
return
|
|
}
|
|
|
|
this.isSettingProxy = true
|
|
|
|
try {
|
|
this.config = config
|
|
this.clearSystemProxyMonitor()
|
|
if (config.mode === 'system') {
|
|
const currentProxy = await getSystemProxy()
|
|
if (currentProxy) {
|
|
logger.info(`current system proxy: ${currentProxy.proxyUrl}`)
|
|
this.config.proxyRules = currentProxy.proxyUrl.toLowerCase()
|
|
}
|
|
this.monitorSystemProxy()
|
|
}
|
|
|
|
byPassRules = config.proxyBypassRules?.split(',') || defaultByPassRules.split(',')
|
|
this.setGlobalProxy(this.config)
|
|
} catch (error) {
|
|
logger.error('Failed to config proxy:', error as Error)
|
|
throw error
|
|
} finally {
|
|
this.isSettingProxy = false
|
|
}
|
|
}
|
|
|
|
private setEnvironment(url: string): void {
|
|
if (url === '') {
|
|
delete process.env.HTTP_PROXY
|
|
delete process.env.HTTPS_PROXY
|
|
delete process.env.grpc_proxy
|
|
delete process.env.http_proxy
|
|
delete process.env.https_proxy
|
|
|
|
delete process.env.SOCKS_PROXY
|
|
delete process.env.ALL_PROXY
|
|
return
|
|
}
|
|
|
|
process.env.grpc_proxy = url
|
|
process.env.HTTP_PROXY = url
|
|
process.env.HTTPS_PROXY = url
|
|
process.env.http_proxy = url
|
|
process.env.https_proxy = url
|
|
|
|
if (url.startsWith('socks')) {
|
|
process.env.SOCKS_PROXY = url
|
|
process.env.ALL_PROXY = url
|
|
}
|
|
}
|
|
|
|
private setGlobalProxy(config: ProxyConfig) {
|
|
this.setEnvironment(config.proxyRules || '')
|
|
this.setGlobalFetchProxy(config)
|
|
this.setSessionsProxy(config)
|
|
|
|
this.setGlobalHttpProxy(config)
|
|
}
|
|
|
|
private setGlobalHttpProxy(config: ProxyConfig) {
|
|
if (config.mode === 'direct' || !config.proxyRules) {
|
|
http.get = this.originalHttpGet
|
|
http.request = this.originalHttpRequest
|
|
https.get = this.originalHttpsGet
|
|
https.request = this.originalHttpsRequest
|
|
try {
|
|
this.proxyAgent?.destroy()
|
|
} catch (error) {
|
|
logger.error('Failed to destroy proxy agent:', error as Error)
|
|
}
|
|
this.proxyAgent = null
|
|
return
|
|
}
|
|
|
|
// ProxyAgent 从环境变量读取代理配置
|
|
const agent = new ProxyAgent()
|
|
this.proxyAgent = agent
|
|
http.get = this.bindHttpMethod(this.originalHttpGet, agent)
|
|
http.request = this.bindHttpMethod(this.originalHttpRequest, agent)
|
|
|
|
https.get = this.bindHttpMethod(this.originalHttpsGet, agent)
|
|
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent)
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
|
|
return (...args: any[]) => {
|
|
let url: string | URL | undefined
|
|
let options: http.RequestOptions | https.RequestOptions
|
|
let callback: (res: http.IncomingMessage) => void
|
|
|
|
if (typeof args[0] === 'string' || args[0] instanceof URL) {
|
|
url = args[0]
|
|
if (typeof args[1] === 'function') {
|
|
options = {}
|
|
callback = args[1]
|
|
} else {
|
|
options = {
|
|
...args[1]
|
|
}
|
|
callback = args[2]
|
|
}
|
|
} else {
|
|
options = {
|
|
...args[0]
|
|
}
|
|
callback = args[1]
|
|
}
|
|
|
|
// filter localhost
|
|
if (url) {
|
|
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname
|
|
if (isByPass(hostname)) {
|
|
return originalMethod(url, options, callback)
|
|
}
|
|
}
|
|
|
|
// for webdav https self-signed certificate
|
|
if (options.agent instanceof https.Agent) {
|
|
;(agent as https.Agent).options.rejectUnauthorized = options.agent.options.rejectUnauthorized
|
|
}
|
|
options.agent = agent
|
|
if (url) {
|
|
return originalMethod(url, options, callback)
|
|
}
|
|
return originalMethod(options, callback)
|
|
}
|
|
}
|
|
|
|
private setGlobalFetchProxy(config: ProxyConfig) {
|
|
const proxyUrl = config.proxyRules
|
|
if (config.mode === 'direct' || !proxyUrl) {
|
|
setGlobalDispatcher(this.originalGlobalDispatcher)
|
|
global[Symbol.for('undici.globalDispatcher.1')] = this.originalSocksDispatcher
|
|
axios.defaults.adapter = 'http'
|
|
this.proxyDispatcher?.close()
|
|
this.proxyDispatcher = null
|
|
return
|
|
}
|
|
|
|
// axios 使用 fetch 代理
|
|
axios.defaults.adapter = 'fetch'
|
|
|
|
const url = new URL(proxyUrl)
|
|
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
this.proxyDispatcher = new SelectiveDispatcher(new EnvHttpProxyAgent(), this.originalGlobalDispatcher)
|
|
setGlobalDispatcher(this.proxyDispatcher)
|
|
return
|
|
}
|
|
|
|
this.proxyDispatcher = new SelectiveDispatcher(
|
|
socksDispatcher({
|
|
port: parseInt(url.port),
|
|
type: url.protocol === 'socks4:' ? 4 : 5,
|
|
host: url.hostname,
|
|
userId: url.username || undefined,
|
|
password: url.password || undefined
|
|
}),
|
|
this.originalSocksDispatcher
|
|
)
|
|
global[Symbol.for('undici.globalDispatcher.1')] = this.proxyDispatcher
|
|
}
|
|
|
|
private async setSessionsProxy(config: ProxyConfig): Promise<void> {
|
|
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
|
await Promise.all(sessions.map((session) => session.setProxy(config)))
|
|
|
|
// set proxy for electron
|
|
app.setProxy(config)
|
|
}
|
|
}
|
|
|
|
export const proxyManager = new ProxyManager()
|