fix(desktop): stabilize proxy and trace flows
This commit is contained in:
parent
bbe21e14df
commit
a1eb4e244c
1433
frontend/src/App.tsx
1433
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,10 @@ import type {
|
|||||||
ProviderStateInput,
|
ProviderStateInput,
|
||||||
ProviderUpsertInput,
|
ProviderUpsertInput,
|
||||||
ProviderView,
|
ProviderView,
|
||||||
|
ProxySettingsSaveResult,
|
||||||
|
ProxySettingsView,
|
||||||
ProxyStatusView,
|
ProxyStatusView,
|
||||||
|
RequestTrace,
|
||||||
SyncInput,
|
SyncInput,
|
||||||
SyncPreview,
|
SyncPreview,
|
||||||
SyncResult,
|
SyncResult,
|
||||||
@ -157,6 +160,20 @@ export function getProxyStatus(): Promise<ProxyStatusView> {
|
|||||||
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
|
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProxySettings(): Promise<ProxySettingsView> {
|
||||||
|
return isWails() ? bridge().ProxySettings() : http<ProxySettingsView>('/api/proxy/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProxySettings(input: ProxySettingsView): Promise<ProxySettingsSaveResult> {
|
||||||
|
return isWails()
|
||||||
|
? bridge().SaveProxySettings(input)
|
||||||
|
: http<ProxySettingsSaveResult>('/api/proxy/settings', { method: 'POST', body: JSON.stringify(input) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRequestTraces(): Promise<RequestTrace[]> {
|
||||||
|
return isWails() ? bridge().RequestTraces(100) : http<RequestTrace[]>('/api/proxy/traces')
|
||||||
|
}
|
||||||
|
|
||||||
export function startProxy(): Promise<ProxyStatusView> {
|
export function startProxy(): Promise<ProxyStatusView> {
|
||||||
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
|
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|||||||
3
frontend/src/env.d.ts
vendored
3
frontend/src/env.d.ts
vendored
@ -22,7 +22,10 @@ declare global {
|
|||||||
UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||||
DoctorRun: () => Promise<import('./types').DoctorRunResult>
|
DoctorRun: () => Promise<import('./types').DoctorRunResult>
|
||||||
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
||||||
|
ProxySettings: () => Promise<import('./types').ProxySettingsView>
|
||||||
|
RequestTraces: (limit: number) => Promise<import('./types').RequestTrace[]>
|
||||||
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
||||||
|
SaveProxySettings: (input: import('./types').ProxySettingsView) => Promise<import('./types').ProxySettingsSaveResult>
|
||||||
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
||||||
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
||||||
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
|
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
"overview": { "title": "Overview", "description": "Status, actions, and environment" },
|
"overview": { "title": "Overview", "description": "Status, actions, and environment" },
|
||||||
"providers": { "title": "Providers", "description": "Manage upstream endpoints" },
|
"providers": { "title": "Providers", "description": "Manage upstream endpoints" },
|
||||||
"aliases": { "title": "Aliases", "description": "Route models across targets" },
|
"aliases": { "title": "Aliases", "description": "Route models across targets" },
|
||||||
|
"log": { "title": "Log", "description": "Business routing, alias decisions, and failover chain" },
|
||||||
|
"network": { "title": "Network", "description": "Request metadata except LLM input and output content" },
|
||||||
"sync": { "title": "Sync", "description": "OpenCode sync and doctor" },
|
"sync": { "title": "Sync", "description": "OpenCode sync and doctor" },
|
||||||
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
|
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
|
||||||
},
|
},
|
||||||
@ -32,7 +34,6 @@
|
|||||||
"unbind": "Unbind",
|
"unbind": "Unbind",
|
||||||
"newProvider": "New provider",
|
"newProvider": "New provider",
|
||||||
"newAlias": "New alias",
|
"newAlias": "New alias",
|
||||||
"useInBindForm": "Use in bind form",
|
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@ -112,6 +113,9 @@
|
|||||||
"filterEnabled": "Enabled",
|
"filterEnabled": "Enabled",
|
||||||
"filterDisabled": "Disabled",
|
"filterDisabled": "Disabled",
|
||||||
"listCount": "{{shown}} shown / {{total}} total",
|
"listCount": "{{shown}} shown / {{total}} total",
|
||||||
|
"detailHint": "Select one row to inspect and edit it on the right.",
|
||||||
|
"selectTitle": "Select a provider",
|
||||||
|
"selectHint": "The left list is optimized for density. Pick one row to inspect or edit details here.",
|
||||||
"empty": "No providers configured yet.",
|
"empty": "No providers configured yet.",
|
||||||
"emptyHint": "Create one manually or import providers from your OpenCode config.",
|
"emptyHint": "Create one manually or import providers from your OpenCode config.",
|
||||||
"noMatches": "No providers match the current filters.",
|
"noMatches": "No providers match the current filters.",
|
||||||
@ -159,6 +163,9 @@
|
|||||||
"search": "Search aliases",
|
"search": "Search aliases",
|
||||||
"searchPlaceholder": "Filter by alias, display name, provider, or model",
|
"searchPlaceholder": "Filter by alias, display name, provider, or model",
|
||||||
"listCount": "{{shown}} shown / {{total}} total",
|
"listCount": "{{shown}} shown / {{total}} total",
|
||||||
|
"detailHint": "Select one row to inspect alias settings and targets on the right.",
|
||||||
|
"selectTitle": "Select an alias",
|
||||||
|
"selectHint": "The left list stays compact so you can scan more routes per screen. Pick one row to inspect or edit details here.",
|
||||||
"empty": "No aliases configured yet.",
|
"empty": "No aliases configured yet.",
|
||||||
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
|
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
|
||||||
"noMatches": "No aliases match the current filters.",
|
"noMatches": "No aliases match the current filters.",
|
||||||
@ -209,12 +216,51 @@
|
|||||||
"debugTitle": "Raw doctor JSON",
|
"debugTitle": "Raw doctor JSON",
|
||||||
"debugSummary": "Show doctor output"
|
"debugSummary": "Show doctor output"
|
||||||
},
|
},
|
||||||
|
"log": {
|
||||||
|
"title": "Request log",
|
||||||
|
"subtitle": "Inspect alias routing, final target, and failover chain for each request.",
|
||||||
|
"count": "{{count}} requests",
|
||||||
|
"empty": "No proxied requests yet.",
|
||||||
|
"emptyHint": "Start the proxy and send a request to populate the timeline.",
|
||||||
|
"success": "Success",
|
||||||
|
"failed": "Failed",
|
||||||
|
"failover": "Failover",
|
||||||
|
"detailTitle": "Business detail",
|
||||||
|
"detailSubtitle": "This panel focuses on alias-level routing semantics.",
|
||||||
|
"alias": "Alias",
|
||||||
|
"finalRoute": "Final route",
|
||||||
|
"status": "Status",
|
||||||
|
"totalTime": "Total time",
|
||||||
|
"startedAt": "Started at",
|
||||||
|
"firstByte": "First byte",
|
||||||
|
"stream": "Stream",
|
||||||
|
"chainTitle": "Attempt chain",
|
||||||
|
"attemptLabel": "Attempt {{attempt}}",
|
||||||
|
"provider": "Provider",
|
||||||
|
"model": "Model"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Network detail",
|
||||||
|
"subtitle": "Inspect URL, timing, status, headers, and params without exposing model IO content.",
|
||||||
|
"detailTitle": "Network inspector",
|
||||||
|
"detailSubtitle": "Each attempt shows technical metadata captured around the upstream call.",
|
||||||
|
"url": "URL",
|
||||||
|
"firstByte": "TTFB",
|
||||||
|
"totalTime": "Total time",
|
||||||
|
"statusCode": "Status code",
|
||||||
|
"requestHeaders": "Client request headers",
|
||||||
|
"requestParams": "Client request params",
|
||||||
|
"responseHeaders": "Upstream response headers",
|
||||||
|
"responseBody": "Upstream response body",
|
||||||
|
"attemptTitle": "Attempt {{attempt}} · {{provider}} / {{model}}"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"subtitle": "Persist desktop behavior, theme preference, and language preference.",
|
"subtitle": "Persist desktop behavior, theme preference, and language preference.",
|
||||||
"appearanceTitle": "Appearance",
|
"appearanceTitle": "Appearance",
|
||||||
"languageTitle": "Language",
|
"languageTitle": "Language",
|
||||||
"behaviorTitle": "Desktop behavior",
|
"behaviorTitle": "Desktop behavior",
|
||||||
|
"timeoutTitle": "Proxy timeouts",
|
||||||
"configTitle": "Config file",
|
"configTitle": "Config file",
|
||||||
"importTitle": "Import mode",
|
"importTitle": "Import mode",
|
||||||
"importModeText": "Import text",
|
"importModeText": "Import text",
|
||||||
@ -241,6 +287,15 @@
|
|||||||
"resolvedTheme": "Resolved theme",
|
"resolvedTheme": "Resolved theme",
|
||||||
"resolvedLanguage": "Resolved language",
|
"resolvedLanguage": "Resolved language",
|
||||||
"launchAtLogin": "Launch at login",
|
"launchAtLogin": "Launch at login",
|
||||||
|
"autoStartProxy": "Start proxy on app launch",
|
||||||
|
"connectTimeout": "Connect timeout (ms)",
|
||||||
|
"responseHeaderTimeout": "Response header timeout (ms)",
|
||||||
|
"firstByteTimeout": "First byte timeout (ms)",
|
||||||
|
"requestReadTimeout": "Client request read timeout (ms)",
|
||||||
|
"streamIdleTimeout": "Stream idle timeout (ms)",
|
||||||
|
"timeoutHint": "Saved values apply to the next proxy start.",
|
||||||
|
"timeoutRunningHint": "The proxy is currently running. Saved values apply only after you restart it.",
|
||||||
|
"saveTimeouts": "Save proxy timeouts",
|
||||||
"minimizeToTray": "Minimize to tray / close to background",
|
"minimizeToTray": "Minimize to tray / close to background",
|
||||||
"notifications": "Native notifications"
|
"notifications": "Native notifications"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
"overview": { "title": "概览", "description": "状态、操作与环境信息" },
|
"overview": { "title": "概览", "description": "状态、操作与环境信息" },
|
||||||
"providers": { "title": "提供商", "description": "管理上游端点" },
|
"providers": { "title": "提供商", "description": "管理上游端点" },
|
||||||
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
|
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
|
||||||
|
"log": { "title": "日志", "description": "查看业务路由、alias 决策与 failover 链路" },
|
||||||
|
"network": { "title": "网络", "description": "查看除 LLM 输入输出内容外的全部请求元数据" },
|
||||||
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
|
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
|
||||||
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
|
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
|
||||||
},
|
},
|
||||||
@ -32,7 +34,6 @@
|
|||||||
"unbind": "解绑",
|
"unbind": "解绑",
|
||||||
"newProvider": "新建提供商",
|
"newProvider": "新建提供商",
|
||||||
"newAlias": "新建别名",
|
"newAlias": "新建别名",
|
||||||
"useInBindForm": "带入绑定表单",
|
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
@ -112,6 +113,9 @@
|
|||||||
"filterEnabled": "已启用",
|
"filterEnabled": "已启用",
|
||||||
"filterDisabled": "已禁用",
|
"filterDisabled": "已禁用",
|
||||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||||
|
"detailHint": "左侧选中一行后,在右侧查看并编辑详情。",
|
||||||
|
"selectTitle": "选择一个提供商",
|
||||||
|
"selectHint": "左侧列表已压缩为高密度模式,适合首屏浏览更多条目;选中一行后在这里查看或编辑详情。",
|
||||||
"empty": "还没有配置任何提供商。",
|
"empty": "还没有配置任何提供商。",
|
||||||
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
|
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
|
||||||
"noMatches": "当前筛选条件下没有匹配的提供商。",
|
"noMatches": "当前筛选条件下没有匹配的提供商。",
|
||||||
@ -159,6 +163,9 @@
|
|||||||
"search": "搜索别名",
|
"search": "搜索别名",
|
||||||
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
|
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
|
||||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||||
|
"detailHint": "左侧选中一行后,在右侧查看别名设置和目标详情。",
|
||||||
|
"selectTitle": "选择一个别名",
|
||||||
|
"selectHint": "左侧列表保持紧凑,方便一屏浏览更多路由;选中一行后在这里查看或编辑详情。",
|
||||||
"empty": "还没有配置任何别名。",
|
"empty": "还没有配置任何别名。",
|
||||||
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
|
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
|
||||||
"noMatches": "当前筛选条件下没有匹配的别名。",
|
"noMatches": "当前筛选条件下没有匹配的别名。",
|
||||||
@ -209,12 +216,51 @@
|
|||||||
"debugTitle": "原始 Doctor JSON",
|
"debugTitle": "原始 Doctor JSON",
|
||||||
"debugSummary": "查看 Doctor 输出"
|
"debugSummary": "查看 Doctor 输出"
|
||||||
},
|
},
|
||||||
|
"log": {
|
||||||
|
"title": "请求日志",
|
||||||
|
"subtitle": "按请求查看 alias 路由、最终目标与 failover 链路。",
|
||||||
|
"count": "共 {{count}} 条请求",
|
||||||
|
"empty": "还没有代理请求。",
|
||||||
|
"emptyHint": "先启动代理并发起一次请求,时间线才会出现内容。",
|
||||||
|
"success": "成功",
|
||||||
|
"failed": "失败",
|
||||||
|
"failover": "发生切换",
|
||||||
|
"detailTitle": "业务详情",
|
||||||
|
"detailSubtitle": "这里聚焦 alias 级别的业务路由语义。",
|
||||||
|
"alias": "Alias",
|
||||||
|
"finalRoute": "最终路由",
|
||||||
|
"status": "状态码",
|
||||||
|
"totalTime": "总耗时",
|
||||||
|
"startedAt": "开始时间",
|
||||||
|
"firstByte": "首字节时间",
|
||||||
|
"stream": "流式",
|
||||||
|
"chainTitle": "尝试链路",
|
||||||
|
"attemptLabel": "第 {{attempt}} 次尝试",
|
||||||
|
"provider": "提供商",
|
||||||
|
"model": "模型"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "网络详情",
|
||||||
|
"subtitle": "查看 URL、时延、状态码、请求头与参数,同时避免暴露模型输入输出正文。",
|
||||||
|
"detailTitle": "网络检查器",
|
||||||
|
"detailSubtitle": "每次尝试都会展示调用上游时采集到的技术元数据。",
|
||||||
|
"url": "URL",
|
||||||
|
"firstByte": "TTFB",
|
||||||
|
"totalTime": "总耗时",
|
||||||
|
"statusCode": "状态码",
|
||||||
|
"requestHeaders": "客户端请求头",
|
||||||
|
"requestParams": "客户端请求参数",
|
||||||
|
"responseHeaders": "上游响应头",
|
||||||
|
"responseBody": "上游响应体",
|
||||||
|
"attemptTitle": "第 {{attempt}} 次尝试 · {{provider}} / {{model}}"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"subtitle": "持久化桌面行为、主题偏好和语言偏好。",
|
"subtitle": "持久化桌面行为、主题偏好和语言偏好。",
|
||||||
"appearanceTitle": "外观",
|
"appearanceTitle": "外观",
|
||||||
"languageTitle": "语言",
|
"languageTitle": "语言",
|
||||||
"behaviorTitle": "桌面行为",
|
"behaviorTitle": "桌面行为",
|
||||||
|
"timeoutTitle": "代理超时",
|
||||||
"configTitle": "配置文件",
|
"configTitle": "配置文件",
|
||||||
"importTitle": "导入方式",
|
"importTitle": "导入方式",
|
||||||
"importModeText": "文本导入",
|
"importModeText": "文本导入",
|
||||||
@ -241,6 +287,15 @@
|
|||||||
"resolvedTheme": "实际主题",
|
"resolvedTheme": "实际主题",
|
||||||
"resolvedLanguage": "实际语言",
|
"resolvedLanguage": "实际语言",
|
||||||
"launchAtLogin": "开机启动",
|
"launchAtLogin": "开机启动",
|
||||||
|
"autoStartProxy": "启动应用时自动开启代理",
|
||||||
|
"connectTimeout": "连接超时(毫秒)",
|
||||||
|
"responseHeaderTimeout": "响应头超时(毫秒)",
|
||||||
|
"firstByteTimeout": "首字节超时(毫秒)",
|
||||||
|
"requestReadTimeout": "客户端请求读取超时(毫秒)",
|
||||||
|
"streamIdleTimeout": "流空闲超时(毫秒)",
|
||||||
|
"timeoutHint": "保存后会在下一次启动代理时生效。",
|
||||||
|
"timeoutRunningHint": "代理当前正在运行,保存后的值要在重启代理后才会生效。",
|
||||||
|
"saveTimeouts": "保存代理超时",
|
||||||
"minimizeToTray": "最小化到托盘 / 关闭时转入后台",
|
"minimizeToTray": "最小化到托盘 / 关闭时转入后台",
|
||||||
"notifications": "系统通知"
|
"notifications": "系统通知"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -263,8 +263,7 @@ html[data-theme='dark'] .sidebar {
|
|||||||
|
|
||||||
.sidebar-brand,
|
.sidebar-brand,
|
||||||
.panel,
|
.panel,
|
||||||
.subpanel,
|
.subpanel {
|
||||||
.workspace-header {
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@ -304,7 +303,6 @@ html[data-theme='dark'] .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow,
|
.eyebrow,
|
||||||
.section-kicker,
|
|
||||||
.meta-label,
|
.meta-label,
|
||||||
.stat-label,
|
.stat-label,
|
||||||
dt {
|
dt {
|
||||||
@ -316,7 +314,6 @@ dt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand h1,
|
.sidebar-brand h1,
|
||||||
.workspace-header h2,
|
|
||||||
.panel h3,
|
.panel h3,
|
||||||
.subpanel h4 {
|
.subpanel h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -326,10 +323,6 @@ dt {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-header h2 {
|
|
||||||
font-size: clamp(1.85rem, 2vw, 2.45rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtle {
|
.subtle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@ -388,7 +381,6 @@ html[data-theme='dark'] .nav-item.active {
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'] .nav-item.active .nav-description,
|
|
||||||
html[data-theme='dark'] .nav-item.active .nav-title {
|
html[data-theme='dark'] .nav-item.active .nav-title {
|
||||||
color: #08111f;
|
color: #08111f;
|
||||||
}
|
}
|
||||||
@ -399,13 +391,8 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-description {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header,
|
.panel-header,
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.hero-actions,
|
|
||||||
.item-heading,
|
.item-heading,
|
||||||
.subpanel-header,
|
.subpanel-header,
|
||||||
.list-toolbar {
|
.list-toolbar {
|
||||||
@ -423,8 +410,7 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
|||||||
.stack,
|
.stack,
|
||||||
.stack-blocks,
|
.stack-blocks,
|
||||||
.issue-stack,
|
.issue-stack,
|
||||||
.target-list,
|
.target-list {
|
||||||
.hero-meta {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
@ -433,7 +419,6 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
|||||||
.target-card,
|
.target-card,
|
||||||
.item-card,
|
.item-card,
|
||||||
.stat-card,
|
.stat-card,
|
||||||
.hero-meta-card,
|
|
||||||
.inline-meta {
|
.inline-meta {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@ -448,75 +433,6 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-header {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.25rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, var(--panel-strong), var(--panel-soft)),
|
|
||||||
radial-gradient(circle at top right, var(--accent-soft), transparent 32%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy .subtle {
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-meta {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
flex: 1;
|
|
||||||
min-width: min(100%, 460px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-meta-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 1rem 1.05rem;
|
|
||||||
transition:
|
|
||||||
border-color 160ms ease,
|
|
||||||
background 160ms ease,
|
|
||||||
box-shadow 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-meta-card:hover {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-meta-card strong {
|
|
||||||
font-size: 1rem;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-status-card {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-status-card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: auto -22px -40px auto;
|
|
||||||
width: 92px;
|
|
||||||
height: 92px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: radial-gradient(circle, var(--accent-soft), transparent 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-layout {
|
.tab-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@ -528,10 +444,15 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sync-layout,
|
.sync-layout,
|
||||||
|
.trace-layout,
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trace-layout {
|
||||||
|
grid-template-columns: minmax(300px, 0.78fr) minmax(0, 1.22fr);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr);
|
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@ -552,6 +473,85 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trace-list-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
max-height: calc(100vh - 18rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--panel-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-row.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-row-top,
|
||||||
|
.trace-row-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-row-summary {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-hero > div,
|
||||||
|
.chain-card,
|
||||||
|
.trace-detail-block {
|
||||||
|
padding: 0.95rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-hero > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-pill-warn {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.action-grid,
|
.action-grid,
|
||||||
.info-grid,
|
.info-grid,
|
||||||
@ -681,6 +681,11 @@ html[data-theme='dark'] button.ghost-danger:hover {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.providers-layout,
|
||||||
|
.aliases-layout {
|
||||||
|
grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.08fr);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-column,
|
.editor-column,
|
||||||
.list-column {
|
.list-column {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -717,6 +722,78 @@ html[data-theme='dark'] button.ghost-danger:hover {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-list {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel-muted);
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row:hover,
|
||||||
|
.compact-row.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-main,
|
||||||
|
.compact-row-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-titleblock {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-titleblock code,
|
||||||
|
.compact-row-summary span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-titleblock strong,
|
||||||
|
.compact-row-titleblock code,
|
||||||
|
.compact-row-summary span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(110px, auto));
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-inline-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-inline-meta > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll-list::-webkit-scrollbar {
|
.scroll-list::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
}
|
}
|
||||||
@ -1108,15 +1185,14 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
|
||||||
|
|
||||||
.nav-description {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-layout,
|
.overview-layout,
|
||||||
.sync-layout,
|
.sync-layout,
|
||||||
|
.trace-layout,
|
||||||
|
.providers-layout,
|
||||||
|
.aliases-layout,
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.card-grid-list,
|
.card-grid-list,
|
||||||
.info-grid,
|
.info-grid,
|
||||||
@ -1139,9 +1215,7 @@ dd {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-header,
|
|
||||||
.panel-header,
|
.panel-header,
|
||||||
.hero-actions,
|
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.card-actions,
|
.card-actions,
|
||||||
.subpanel-header,
|
.subpanel-header,
|
||||||
@ -1150,7 +1224,7 @@ dd {
|
|||||||
.target-card-main,
|
.target-card-main,
|
||||||
.item-heading,
|
.item-heading,
|
||||||
.item-summary,
|
.item-summary,
|
||||||
.hero-actions-wrap,
|
.compact-row-main,
|
||||||
.inline-meta {
|
.inline-meta {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@ -1164,6 +1238,7 @@ dd {
|
|||||||
.nav-list,
|
.nav-list,
|
||||||
.overview-layout,
|
.overview-layout,
|
||||||
.sync-layout,
|
.sync-layout,
|
||||||
|
.trace-layout,
|
||||||
.settings-layout,
|
.settings-layout,
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.card-grid-list,
|
.card-grid-list,
|
||||||
@ -1171,7 +1246,7 @@ dd {
|
|||||||
.item-grid,
|
.item-grid,
|
||||||
.action-grid,
|
.action-grid,
|
||||||
.split-view,
|
.split-view,
|
||||||
.hero-meta {
|
.compact-row-summary-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1183,13 +1258,18 @@ dd {
|
|||||||
.panel-fill {
|
.panel-fill {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trace-row-top,
|
||||||
|
.trace-row-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.sidebar-brand,
|
.sidebar-brand,
|
||||||
.panel,
|
.panel,
|
||||||
.subpanel,
|
.subpanel {
|
||||||
.workspace-header {
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1202,10 +1282,6 @@ dd {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-header {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel,
|
.panel,
|
||||||
.subpanel {
|
.subpanel {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
|
|||||||
|
|
||||||
export type DesktopPrefsView = {
|
export type DesktopPrefsView = {
|
||||||
launchAtLogin: boolean
|
launchAtLogin: boolean
|
||||||
|
autoStartProxy: boolean
|
||||||
minimizeToTray: boolean
|
minimizeToTray: boolean
|
||||||
notifications: boolean
|
notifications: boolean
|
||||||
theme: ThemePreference
|
theme: ThemePreference
|
||||||
@ -15,6 +16,19 @@ export type DesktopPrefsSaveResult = {
|
|||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProxySettingsView = {
|
||||||
|
connectTimeoutMs: number
|
||||||
|
responseHeaderTimeoutMs: number
|
||||||
|
firstByteTimeoutMs: number
|
||||||
|
requestReadTimeoutMs: number
|
||||||
|
streamIdleTimeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxySettingsSaveResult = {
|
||||||
|
settings: ProxySettingsView
|
||||||
|
warnings?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ConfigExportView = {
|
export type ConfigExportView = {
|
||||||
configPath: string
|
configPath: string
|
||||||
content: string
|
content: string
|
||||||
@ -36,6 +50,48 @@ export type ProxyStatusView = {
|
|||||||
lastError?: string
|
lastError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TraceAttempt = {
|
||||||
|
attempt: number
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
url?: string
|
||||||
|
startedAt: string
|
||||||
|
durationMs: number
|
||||||
|
firstByteMs?: number
|
||||||
|
statusCode?: number
|
||||||
|
success: boolean
|
||||||
|
retryable: boolean
|
||||||
|
skipped: boolean
|
||||||
|
result?: string
|
||||||
|
error?: string
|
||||||
|
requestHeaders?: Record<string, string>
|
||||||
|
requestParams?: unknown
|
||||||
|
responseHeaders?: Record<string, string>
|
||||||
|
responseBody?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestTrace = {
|
||||||
|
id: number
|
||||||
|
startedAt: string
|
||||||
|
finishedAt?: string
|
||||||
|
durationMs: number
|
||||||
|
firstByteMs?: number
|
||||||
|
rawModel?: string
|
||||||
|
alias?: string
|
||||||
|
stream: boolean
|
||||||
|
success: boolean
|
||||||
|
statusCode?: number
|
||||||
|
error?: string
|
||||||
|
finalProvider?: string
|
||||||
|
finalModel?: string
|
||||||
|
finalUrl?: string
|
||||||
|
failover: boolean
|
||||||
|
attemptCount: number
|
||||||
|
requestHeaders?: Record<string, string>
|
||||||
|
requestParams?: unknown
|
||||||
|
attempts: TraceAttempt[]
|
||||||
|
}
|
||||||
|
|
||||||
export type Overview = {
|
export type Overview = {
|
||||||
configPath: string
|
configPath: string
|
||||||
providerCount: number
|
providerCount: number
|
||||||
|
|||||||
6
frontend/wailsjs/go/desktop/App.d.ts
vendored
6
frontend/wailsjs/go/desktop/App.d.ts
vendored
@ -36,8 +36,12 @@ export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
|
|||||||
|
|
||||||
export function Providers():Promise<Array<app.ProviderView>>;
|
export function Providers():Promise<Array<app.ProviderView>>;
|
||||||
|
|
||||||
|
export function ProxySettings():Promise<app.ProxySettingsView>;
|
||||||
|
|
||||||
export function ProxyStatus():Promise<app.ProxyStatusView>;
|
export function ProxyStatus():Promise<app.ProxyStatusView>;
|
||||||
|
|
||||||
|
export function RequestTraces(arg1:number):Promise<Array<app.RequestTrace>>;
|
||||||
|
|
||||||
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
|
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
|
||||||
|
|
||||||
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
|
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
|
||||||
@ -46,6 +50,8 @@ export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsSa
|
|||||||
|
|
||||||
export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>;
|
export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>;
|
||||||
|
|
||||||
|
export function SaveProxySettings(arg1:app.ProxySettingsInput):Promise<app.ProxySettingsSaveResult>;
|
||||||
|
|
||||||
export function Service():Promise<app.Service>;
|
export function Service():Promise<app.Service>;
|
||||||
|
|
||||||
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;
|
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;
|
||||||
|
|||||||
@ -66,10 +66,18 @@ export function Providers() {
|
|||||||
return window['go']['desktop']['App']['Providers']();
|
return window['go']['desktop']['App']['Providers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ProxySettings() {
|
||||||
|
return window['go']['desktop']['App']['ProxySettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ProxyStatus() {
|
export function ProxyStatus() {
|
||||||
return window['go']['desktop']['App']['ProxyStatus']();
|
return window['go']['desktop']['App']['ProxyStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RequestTraces(arg1) {
|
||||||
|
return window['go']['desktop']['App']['RequestTraces'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAlias(arg1) {
|
export function SaveAlias(arg1) {
|
||||||
return window['go']['desktop']['App']['SaveAlias'](arg1);
|
return window['go']['desktop']['App']['SaveAlias'](arg1);
|
||||||
}
|
}
|
||||||
@ -86,6 +94,10 @@ export function SaveProvider(arg1) {
|
|||||||
return window['go']['desktop']['App']['SaveProvider'](arg1);
|
return window['go']['desktop']['App']['SaveProvider'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveProxySettings(arg1) {
|
||||||
|
return window['go']['desktop']['App']['SaveProxySettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function Service() {
|
export function Service() {
|
||||||
return window['go']['desktop']['App']['Service']();
|
return window['go']['desktop']['App']['Service']();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@ export namespace app {
|
|||||||
}
|
}
|
||||||
export class DesktopPrefsInput {
|
export class DesktopPrefsInput {
|
||||||
launchAtLogin: boolean;
|
launchAtLogin: boolean;
|
||||||
|
autoStartProxy: boolean;
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
@ -144,6 +145,7 @@ export namespace app {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.launchAtLogin = source["launchAtLogin"];
|
this.launchAtLogin = source["launchAtLogin"];
|
||||||
|
this.autoStartProxy = source["autoStartProxy"];
|
||||||
this.minimizeToTray = source["minimizeToTray"];
|
this.minimizeToTray = source["minimizeToTray"];
|
||||||
this.notifications = source["notifications"];
|
this.notifications = source["notifications"];
|
||||||
this.theme = source["theme"];
|
this.theme = source["theme"];
|
||||||
@ -152,6 +154,7 @@ export namespace app {
|
|||||||
}
|
}
|
||||||
export class DesktopPrefsView {
|
export class DesktopPrefsView {
|
||||||
launchAtLogin: boolean;
|
launchAtLogin: boolean;
|
||||||
|
autoStartProxy: boolean;
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
@ -164,6 +167,7 @@ export namespace app {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.launchAtLogin = source["launchAtLogin"];
|
this.launchAtLogin = source["launchAtLogin"];
|
||||||
|
this.autoStartProxy = source["autoStartProxy"];
|
||||||
this.minimizeToTray = source["minimizeToTray"];
|
this.minimizeToTray = source["minimizeToTray"];
|
||||||
this.notifications = source["notifications"];
|
this.notifications = source["notifications"];
|
||||||
this.theme = source["theme"];
|
this.theme = source["theme"];
|
||||||
@ -294,8 +298,7 @@ export namespace app {
|
|||||||
export class ProxyStatusView {
|
export class ProxyStatusView {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
bindAddress: string;
|
bindAddress: string;
|
||||||
// Go type: time
|
startedAt?: string;
|
||||||
startedAt?: any;
|
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@ -306,27 +309,9 @@ export namespace app {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.running = source["running"];
|
this.running = source["running"];
|
||||||
this.bindAddress = source["bindAddress"];
|
this.bindAddress = source["bindAddress"];
|
||||||
this.startedAt = this.convertValues(source["startedAt"], null);
|
this.startedAt = source["startedAt"];
|
||||||
this.lastError = source["lastError"];
|
this.lastError = source["lastError"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
|
||||||
if (!a) {
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
if (a.slice && a.map) {
|
|
||||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
|
||||||
} else if ("object" === typeof a) {
|
|
||||||
if (asMap) {
|
|
||||||
for (const key of Object.keys(a)) {
|
|
||||||
a[key] = new classs(a[key]);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
return new classs(a);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export class Overview {
|
export class Overview {
|
||||||
configPath: string;
|
configPath: string;
|
||||||
@ -501,7 +486,190 @@ export namespace app {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ProxySettingsInput {
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
responseHeaderTimeoutMs: number;
|
||||||
|
firstByteTimeoutMs: number;
|
||||||
|
requestReadTimeoutMs: number;
|
||||||
|
streamIdleTimeoutMs: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ProxySettingsInput(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connectTimeoutMs = source["connectTimeoutMs"];
|
||||||
|
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
|
||||||
|
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
|
||||||
|
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
|
||||||
|
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ProxySettingsView {
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
responseHeaderTimeoutMs: number;
|
||||||
|
firstByteTimeoutMs: number;
|
||||||
|
requestReadTimeoutMs: number;
|
||||||
|
streamIdleTimeoutMs: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ProxySettingsView(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connectTimeoutMs = source["connectTimeoutMs"];
|
||||||
|
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
|
||||||
|
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
|
||||||
|
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
|
||||||
|
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ProxySettingsSaveResult {
|
||||||
|
settings: ProxySettingsView;
|
||||||
|
warnings?: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ProxySettingsSaveResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.settings = this.convertValues(source["settings"], ProxySettingsView);
|
||||||
|
this.warnings = source["warnings"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class TraceAttempt {
|
||||||
|
attempt: number;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
url?: string;
|
||||||
|
startedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
firstByteMs?: number;
|
||||||
|
statusCode?: number;
|
||||||
|
success: boolean;
|
||||||
|
retryable: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
requestHeaders?: Record<string, string>;
|
||||||
|
requestParams?: any;
|
||||||
|
responseHeaders?: Record<string, string>;
|
||||||
|
responseBody?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new TraceAttempt(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.attempt = source["attempt"];
|
||||||
|
this.provider = source["provider"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.url = source["url"];
|
||||||
|
this.startedAt = source["startedAt"];
|
||||||
|
this.durationMs = source["durationMs"];
|
||||||
|
this.firstByteMs = source["firstByteMs"];
|
||||||
|
this.statusCode = source["statusCode"];
|
||||||
|
this.success = source["success"];
|
||||||
|
this.retryable = source["retryable"];
|
||||||
|
this.skipped = source["skipped"];
|
||||||
|
this.result = source["result"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.requestHeaders = source["requestHeaders"];
|
||||||
|
this.requestParams = source["requestParams"];
|
||||||
|
this.responseHeaders = source["responseHeaders"];
|
||||||
|
this.responseBody = source["responseBody"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RequestTrace {
|
||||||
|
id: number;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
durationMs: number;
|
||||||
|
firstByteMs?: number;
|
||||||
|
rawModel?: string;
|
||||||
|
alias?: string;
|
||||||
|
stream: boolean;
|
||||||
|
success: boolean;
|
||||||
|
statusCode?: number;
|
||||||
|
error?: string;
|
||||||
|
finalProvider?: string;
|
||||||
|
finalModel?: string;
|
||||||
|
finalUrl?: string;
|
||||||
|
failover: boolean;
|
||||||
|
attemptCount: number;
|
||||||
|
requestHeaders?: Record<string, string>;
|
||||||
|
requestParams?: any;
|
||||||
|
attempts: TraceAttempt[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RequestTrace(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.startedAt = source["startedAt"];
|
||||||
|
this.finishedAt = source["finishedAt"];
|
||||||
|
this.durationMs = source["durationMs"];
|
||||||
|
this.firstByteMs = source["firstByteMs"];
|
||||||
|
this.rawModel = source["rawModel"];
|
||||||
|
this.alias = source["alias"];
|
||||||
|
this.stream = source["stream"];
|
||||||
|
this.success = source["success"];
|
||||||
|
this.statusCode = source["statusCode"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.finalProvider = source["finalProvider"];
|
||||||
|
this.finalModel = source["finalModel"];
|
||||||
|
this.finalUrl = source["finalUrl"];
|
||||||
|
this.failover = source["failover"];
|
||||||
|
this.attemptCount = source["attemptCount"];
|
||||||
|
this.requestHeaders = source["requestHeaders"];
|
||||||
|
this.requestParams = source["requestParams"];
|
||||||
|
this.attempts = this.convertValues(source["attempts"], TraceAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Service {
|
export class Service {
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,16 +15,18 @@ import (
|
|||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
traces *proxy.TraceStore
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
proxyCancel context.CancelFunc
|
proxyCancel context.CancelFunc
|
||||||
proxyDone chan struct{}
|
proxyDone chan struct{}
|
||||||
|
proxyReady chan error
|
||||||
proxyErr error
|
proxyErr error
|
||||||
proxyStatus ProxyStatusView
|
proxyStatus ProxyStatusView
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(configPath string) *Service {
|
func NewService(configPath string) *Service {
|
||||||
return &Service{configPath: strings.TrimSpace(configPath)}
|
return &Service{configPath: strings.TrimSpace(configPath), traces: proxy.NewTraceStore(200)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ConfigPath() string {
|
func (s *Service) ConfigPath() string {
|
||||||
@ -155,7 +157,6 @@ func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) StartProxy(ctx context.Context) error {
|
func (s *Service) StartProxy(ctx context.Context) error {
|
||||||
_ = ctx
|
|
||||||
cfg, err := s.loadConfig()
|
cfg, err := s.loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -165,29 +166,36 @@ func (s *Service) StartProxy(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindAddress := proxyBindAddress(cfg)
|
bindAddress := proxyBindAddress(cfg)
|
||||||
started := false
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.proxyCancel == nil {
|
if s.proxyCancel == nil {
|
||||||
runCtx, cancel := context.WithCancel(context.Background())
|
runCtx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
ready := make(chan error, 1)
|
||||||
s.proxyCancel = cancel
|
s.proxyCancel = cancel
|
||||||
s.proxyDone = done
|
s.proxyDone = done
|
||||||
|
s.proxyReady = ready
|
||||||
s.proxyErr = nil
|
s.proxyErr = nil
|
||||||
s.proxyStatus = ProxyStatusView{
|
s.proxyStatus = ProxyStatusView{
|
||||||
Running: true,
|
Running: true,
|
||||||
BindAddress: bindAddress,
|
BindAddress: bindAddress,
|
||||||
StartedAt: time.Now(),
|
StartedAt: formatTimestamp(time.Now()),
|
||||||
}
|
}
|
||||||
started = true
|
go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
|
||||||
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
|
|
||||||
}
|
}
|
||||||
|
ready := s.proxyReady
|
||||||
|
done := s.proxyDone
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if !started {
|
select {
|
||||||
return nil
|
case err := <-ready:
|
||||||
|
return err
|
||||||
|
case <-done:
|
||||||
|
return s.WaitProxy(context.Background())
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = s.StopProxy(context.Background())
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) StopProxy(ctx context.Context) error {
|
func (s *Service) StopProxy(ctx context.Context) error {
|
||||||
@ -234,6 +242,47 @@ func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
|
|||||||
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
|
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetProxySettings(ctx context.Context) (ProxySettingsView, error) {
|
||||||
|
_ = ctx
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return ProxySettingsView{}, err
|
||||||
|
}
|
||||||
|
return proxySettingsView(cfg.Server), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SaveProxySettings(ctx context.Context, in ProxySettingsInput) (ProxySettingsSaveResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return ProxySettingsSaveResult{}, err
|
||||||
|
}
|
||||||
|
cfg.Server.ConnectTimeoutMs = normalizePositiveInt(in.ConnectTimeoutMs, config.DefaultConnectTimeoutMs)
|
||||||
|
cfg.Server.ResponseHeaderTimeoutMs = normalizePositiveInt(in.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
|
||||||
|
cfg.Server.FirstByteTimeoutMs = normalizePositiveInt(in.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||||
|
cfg.Server.RequestReadTimeoutMs = normalizePositiveInt(in.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs)
|
||||||
|
cfg.Server.StreamIdleTimeoutMs = normalizePositiveInt(in.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
return ProxySettingsSaveResult{}, err
|
||||||
|
}
|
||||||
|
warnings := []string{}
|
||||||
|
status := s.currentProxyStatus(proxyBindAddress(cfg))
|
||||||
|
if status.Running {
|
||||||
|
warnings = append(warnings, "saved proxy timeout settings; restart proxy to apply changes")
|
||||||
|
}
|
||||||
|
return ProxySettingsSaveResult{Settings: proxySettingsView(cfg.Server), Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListRequestTraces(ctx context.Context, limit int) ([]RequestTrace, error) {
|
||||||
|
_ = ctx
|
||||||
|
raw := s.traces.List(limit)
|
||||||
|
out := make([]RequestTrace, 0, len(raw))
|
||||||
|
for _, trace := range raw {
|
||||||
|
out = append(out, requestTraceView(trace))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
|
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
cfg, err := s.loadConfig()
|
cfg, err := s.loadConfig()
|
||||||
@ -251,6 +300,7 @@ func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (D
|
|||||||
}
|
}
|
||||||
cfg.Desktop = config.Desktop{
|
cfg.Desktop = config.Desktop{
|
||||||
LaunchAtLogin: in.LaunchAtLogin,
|
LaunchAtLogin: in.LaunchAtLogin,
|
||||||
|
AutoStartProxy: in.AutoStartProxy,
|
||||||
MinimizeToTray: in.MinimizeToTray,
|
MinimizeToTray: in.MinimizeToTray,
|
||||||
Notifications: in.Notifications,
|
Notifications: in.Notifications,
|
||||||
Theme: normalizeThemePreference(in.Theme),
|
Theme: normalizeThemePreference(in.Theme),
|
||||||
@ -322,13 +372,14 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) {
|
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, ready chan error, cfg *config.Config, bindAddress string) {
|
||||||
err := proxy.New(cfg).ListenAndServe(runCtx)
|
err := proxy.New(cfg, s.traces).ListenAndServeWithReady(runCtx, ready)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.proxyDone == done {
|
if s.proxyDone == done {
|
||||||
s.proxyCancel = nil
|
s.proxyCancel = nil
|
||||||
s.proxyDone = nil
|
s.proxyDone = nil
|
||||||
|
s.proxyReady = nil
|
||||||
}
|
}
|
||||||
s.proxyErr = err
|
s.proxyErr = err
|
||||||
status := s.proxyStatus
|
status := s.proxyStatus
|
||||||
@ -387,6 +438,7 @@ func doctorIssues(errs []error) []DoctorIssue {
|
|||||||
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
||||||
return DesktopPrefsView{
|
return DesktopPrefsView{
|
||||||
LaunchAtLogin: prefs.LaunchAtLogin,
|
LaunchAtLogin: prefs.LaunchAtLogin,
|
||||||
|
AutoStartProxy: prefs.AutoStartProxy,
|
||||||
MinimizeToTray: prefs.MinimizeToTray,
|
MinimizeToTray: prefs.MinimizeToTray,
|
||||||
Notifications: prefs.Notifications,
|
Notifications: prefs.Notifications,
|
||||||
Theme: normalizeThemePreference(prefs.Theme),
|
Theme: normalizeThemePreference(prefs.Theme),
|
||||||
@ -394,6 +446,23 @@ func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func proxySettingsView(server config.Server) ProxySettingsView {
|
||||||
|
return ProxySettingsView{
|
||||||
|
ConnectTimeoutMs: normalizePositiveInt(server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||||
|
ResponseHeaderTimeoutMs: normalizePositiveInt(server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs),
|
||||||
|
FirstByteTimeoutMs: normalizePositiveInt(server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs),
|
||||||
|
RequestReadTimeoutMs: normalizePositiveInt(server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
|
||||||
|
StreamIdleTimeoutMs: normalizePositiveInt(server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePositiveInt(value int, fallback int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeThemePreference(value string) string {
|
func normalizeThemePreference(value string) string {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
switch trimmed {
|
switch trimmed {
|
||||||
@ -460,6 +529,13 @@ func cloneHeaders(in map[string]string) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatTimestamp(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
func maskKey(k string) string {
|
func maskKey(k string) string {
|
||||||
if k == "" {
|
if k == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -23,6 +23,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
|||||||
|
|
||||||
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
||||||
LaunchAtLogin: true,
|
LaunchAtLogin: true,
|
||||||
|
AutoStartProxy: true,
|
||||||
MinimizeToTray: true,
|
MinimizeToTray: true,
|
||||||
Notifications: true,
|
Notifications: true,
|
||||||
Theme: "dark",
|
Theme: "dark",
|
||||||
@ -31,7 +32,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
||||||
}
|
}
|
||||||
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
|
if !prefs.LaunchAtLogin || !prefs.AutoStartProxy || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
|
||||||
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config.Load() error = %v", err)
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
}
|
}
|
||||||
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
|
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.AutoStartProxy || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
|
||||||
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
|
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,6 +63,102 @@ func TestSaveDesktopPrefsNormalizesUnknownThemeAndLanguage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveProxySettingsPersistsToConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
svc := NewService(path)
|
||||||
|
|
||||||
|
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
|
||||||
|
ConnectTimeoutMs: 12000,
|
||||||
|
ResponseHeaderTimeoutMs: 21000,
|
||||||
|
FirstByteTimeoutMs: 22000,
|
||||||
|
RequestReadTimeoutMs: 33000,
|
||||||
|
StreamIdleTimeoutMs: 70000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveProxySettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Settings.ConnectTimeoutMs != 12000 || result.Settings.ResponseHeaderTimeoutMs != 21000 || result.Settings.FirstByteTimeoutMs != 22000 || result.Settings.RequestReadTimeoutMs != 33000 || result.Settings.StreamIdleTimeoutMs != 70000 {
|
||||||
|
t.Fatalf("SaveProxySettings() = %#v", result.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.ConnectTimeoutMs != 12000 || cfg.Server.ResponseHeaderTimeoutMs != 21000 || cfg.Server.FirstByteTimeoutMs != 22000 || cfg.Server.RequestReadTimeoutMs != 33000 || cfg.Server.StreamIdleTimeoutMs != 70000 {
|
||||||
|
t.Fatalf("persisted server settings = %#v", cfg.Server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveProxySettingsWarnsWhenProxyRunning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
port := freePort(t)
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = port
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(path)
|
||||||
|
if err := svc.StartProxy(context.Background()); err != nil {
|
||||||
|
t.Fatalf("StartProxy() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = svc.StopProxy(context.Background()) }()
|
||||||
|
|
||||||
|
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{ConnectTimeoutMs: 12000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveProxySettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "restart proxy") {
|
||||||
|
t.Fatalf("warnings = %#v", result.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveProxySettingsNormalizesNonPositiveValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
svc := NewService(path)
|
||||||
|
|
||||||
|
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
|
||||||
|
ConnectTimeoutMs: 0,
|
||||||
|
ResponseHeaderTimeoutMs: -1,
|
||||||
|
FirstByteTimeoutMs: 0,
|
||||||
|
RequestReadTimeoutMs: -50,
|
||||||
|
StreamIdleTimeoutMs: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveProxySettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
|
||||||
|
result.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
|
||||||
|
result.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
|
||||||
|
result.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
|
||||||
|
result.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
|
||||||
|
t.Fatalf("normalized settings = %#v", result.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
|
||||||
|
cfg.Server.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
|
||||||
|
cfg.Server.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
|
||||||
|
cfg.Server.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
|
||||||
|
cfg.Server.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
|
||||||
|
t.Fatalf("persisted server settings = %#v", cfg.Server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@ -116,6 +213,104 @@ func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartProxyReturnsBindErrorWithoutRunningStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
port := freePort(t)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = port
|
||||||
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(path)
|
||||||
|
err = svc.StartProxy(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("StartProxy() error = nil, want bind failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
|
||||||
|
t.Fatalf("StartProxy() error = %v, want bind failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, statusErr := svc.GetProxyStatus(context.Background())
|
||||||
|
if statusErr != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() error = %v", statusErr)
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
t.Fatalf("status = %#v, want stopped", status)
|
||||||
|
}
|
||||||
|
if status.LastError == "" {
|
||||||
|
t.Fatalf("status = %#v, want last error", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentStartProxyCallsShareStartupResult(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
port := freePort(t)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = port
|
||||||
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(path)
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
start := make(chan struct{})
|
||||||
|
for range 2 {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
errCh <- svc.StartProxy(context.Background())
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
err := <-errCh
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("StartProxy() error = nil, want bind failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
|
||||||
|
t.Fatalf("StartProxy() error = %v, want bind failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status, statusErr := svc.GetProxyStatus(context.Background())
|
||||||
|
if statusErr != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() error = %v", statusErr)
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
t.Fatalf("status = %#v, want stopped", status)
|
||||||
|
}
|
||||||
|
if status.LastError == "" {
|
||||||
|
t.Fatalf("status = %#v, want last error", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
|
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "time"
|
import "github.com/Apale7/opencode-provider-switch/internal/proxy"
|
||||||
|
|
||||||
type Overview struct {
|
type Overview struct {
|
||||||
ConfigPath string `json:"configPath"`
|
ConfigPath string `json:"configPath"`
|
||||||
@ -12,10 +12,123 @@ type Overview struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProxyStatusView struct {
|
type ProxyStatusView struct {
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
BindAddress string `json:"bindAddress"`
|
BindAddress string `json:"bindAddress"`
|
||||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
StartedAt string `json:"startedAt,omitempty"`
|
||||||
LastError string `json:"lastError,omitempty"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxySettingsView struct {
|
||||||
|
ConnectTimeoutMs int `json:"connectTimeoutMs"`
|
||||||
|
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
|
||||||
|
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
|
||||||
|
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
|
||||||
|
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxySettingsInput struct {
|
||||||
|
ConnectTimeoutMs int `json:"connectTimeoutMs"`
|
||||||
|
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
|
||||||
|
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
|
||||||
|
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
|
||||||
|
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxySettingsSaveResult struct {
|
||||||
|
Settings ProxySettingsView `json:"settings"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestTrace struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
FinishedAt string `json:"finishedAt,omitempty"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||||
|
RawModel string `json:"rawModel,omitempty"`
|
||||||
|
Alias string `json:"alias,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
FinalProvider string `json:"finalProvider,omitempty"`
|
||||||
|
FinalModel string `json:"finalModel,omitempty"`
|
||||||
|
FinalURL string `json:"finalUrl,omitempty"`
|
||||||
|
Failover bool `json:"failover"`
|
||||||
|
AttemptCount int `json:"attemptCount"`
|
||||||
|
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||||
|
RequestParams any `json:"requestParams,omitempty"`
|
||||||
|
Attempts []TraceAttempt `json:"attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceAttempt struct {
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||||
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||||
|
RequestParams any `json:"requestParams,omitempty"`
|
||||||
|
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
|
||||||
|
ResponseBody string `json:"responseBody,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestTraceView(trace proxy.RequestTrace) RequestTrace {
|
||||||
|
attempts := make([]TraceAttempt, 0, len(trace.Attempts))
|
||||||
|
for _, attempt := range trace.Attempts {
|
||||||
|
attempts = append(attempts, traceAttemptView(attempt))
|
||||||
|
}
|
||||||
|
return RequestTrace{
|
||||||
|
ID: trace.ID,
|
||||||
|
StartedAt: formatTimestamp(trace.StartedAt),
|
||||||
|
FinishedAt: formatTimestamp(trace.FinishedAt),
|
||||||
|
DurationMs: trace.DurationMs,
|
||||||
|
FirstByteMs: trace.FirstByteMs,
|
||||||
|
RawModel: trace.RawModel,
|
||||||
|
Alias: trace.Alias,
|
||||||
|
Stream: trace.Stream,
|
||||||
|
Success: trace.Success,
|
||||||
|
StatusCode: trace.StatusCode,
|
||||||
|
Error: trace.Error,
|
||||||
|
FinalProvider: trace.FinalProvider,
|
||||||
|
FinalModel: trace.FinalModel,
|
||||||
|
FinalURL: trace.FinalURL,
|
||||||
|
Failover: trace.Failover,
|
||||||
|
AttemptCount: trace.AttemptCount,
|
||||||
|
RequestHeaders: trace.RequestHeaders,
|
||||||
|
RequestParams: trace.RequestParams,
|
||||||
|
Attempts: attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceAttemptView(attempt proxy.TraceAttempt) TraceAttempt {
|
||||||
|
return TraceAttempt{
|
||||||
|
Attempt: attempt.Attempt,
|
||||||
|
Provider: attempt.Provider,
|
||||||
|
Model: attempt.Model,
|
||||||
|
URL: attempt.URL,
|
||||||
|
StartedAt: formatTimestamp(attempt.StartedAt),
|
||||||
|
DurationMs: attempt.DurationMs,
|
||||||
|
FirstByteMs: attempt.FirstByteMs,
|
||||||
|
StatusCode: attempt.StatusCode,
|
||||||
|
Success: attempt.Success,
|
||||||
|
Retryable: attempt.Retryable,
|
||||||
|
Skipped: attempt.Skipped,
|
||||||
|
Result: attempt.Result,
|
||||||
|
Error: attempt.Error,
|
||||||
|
RequestHeaders: attempt.RequestHeaders,
|
||||||
|
RequestParams: attempt.RequestParams,
|
||||||
|
ResponseHeaders: attempt.ResponseHeaders,
|
||||||
|
ResponseBody: attempt.ResponseBody,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderView struct {
|
type ProviderView struct {
|
||||||
@ -96,6 +209,7 @@ type SyncResult struct {
|
|||||||
|
|
||||||
type DesktopPrefsView struct {
|
type DesktopPrefsView struct {
|
||||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||||
|
AutoStartProxy bool `json:"autoStartProxy"`
|
||||||
MinimizeToTray bool `json:"minimizeToTray"`
|
MinimizeToTray bool `json:"minimizeToTray"`
|
||||||
Notifications bool `json:"notifications"`
|
Notifications bool `json:"notifications"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
@ -109,6 +223,7 @@ type DesktopPrefsSaveResult struct {
|
|||||||
|
|
||||||
type DesktopPrefsInput struct {
|
type DesktopPrefsInput struct {
|
||||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||||
|
AutoStartProxy bool `json:"autoStartProxy"`
|
||||||
MinimizeToTray bool `json:"minimizeToTray"`
|
MinimizeToTray bool `json:"minimizeToTray"`
|
||||||
Notifications bool `json:"notifications"`
|
Notifications bool `json:"notifications"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
|
|||||||
@ -51,14 +51,28 @@ type Provider struct {
|
|||||||
|
|
||||||
// Server holds proxy listen settings.
|
// Server holds proxy listen settings.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
|
ConnectTimeoutMs int `json:"connect_timeout_ms,omitempty"`
|
||||||
|
ResponseHeaderTimeoutMs int `json:"response_header_timeout_ms,omitempty"`
|
||||||
|
FirstByteTimeoutMs int `json:"first_byte_timeout_ms,omitempty"`
|
||||||
|
RequestReadTimeoutMs int `json:"request_read_timeout_ms,omitempty"`
|
||||||
|
StreamIdleTimeoutMs int `json:"stream_idle_timeout_ms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultConnectTimeoutMs = 10_000
|
||||||
|
DefaultResponseHeaderTimeoutMs = 15_000
|
||||||
|
DefaultFirstByteTimeoutMs = 15_000
|
||||||
|
DefaultRequestReadTimeoutMs = 30_000
|
||||||
|
DefaultStreamIdleTimeoutMs = 60_000
|
||||||
|
)
|
||||||
|
|
||||||
// Desktop holds desktop-shell user preferences.
|
// Desktop holds desktop-shell user preferences.
|
||||||
type Desktop struct {
|
type Desktop struct {
|
||||||
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
||||||
|
AutoStartProxy bool `json:"auto_start_proxy,omitempty"`
|
||||||
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
||||||
Notifications bool `json:"notifications,omitempty"`
|
Notifications bool `json:"notifications,omitempty"`
|
||||||
Theme string `json:"theme,omitempty"`
|
Theme string `json:"theme,omitempty"`
|
||||||
@ -104,9 +118,14 @@ func ValidateProviderBaseURL(baseURL string) error {
|
|||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Server: Server{
|
Server: Server{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 9982,
|
Port: 9982,
|
||||||
APIKey: DefaultLocalAPIKey,
|
APIKey: DefaultLocalAPIKey,
|
||||||
|
ConnectTimeoutMs: DefaultConnectTimeoutMs,
|
||||||
|
ResponseHeaderTimeoutMs: DefaultResponseHeaderTimeoutMs,
|
||||||
|
FirstByteTimeoutMs: DefaultFirstByteTimeoutMs,
|
||||||
|
RequestReadTimeoutMs: DefaultRequestReadTimeoutMs,
|
||||||
|
StreamIdleTimeoutMs: DefaultStreamIdleTimeoutMs,
|
||||||
},
|
},
|
||||||
Desktop: Desktop{},
|
Desktop: Desktop{},
|
||||||
Providers: []Provider{},
|
Providers: []Provider{},
|
||||||
@ -158,6 +177,7 @@ func Load(path string) (*Config, error) {
|
|||||||
if c.Server.APIKey == "" {
|
if c.Server.APIKey == "" {
|
||||||
c.Server.APIKey = DefaultLocalAPIKey
|
c.Server.APIKey = DefaultLocalAPIKey
|
||||||
}
|
}
|
||||||
|
normalizeServerTimeouts(&c.Server)
|
||||||
c.path = path
|
c.path = path
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@ -448,8 +468,41 @@ func (c *Config) Validate() []error {
|
|||||||
if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) {
|
if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) {
|
||||||
errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host))
|
errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host))
|
||||||
}
|
}
|
||||||
|
if c.Server.ConnectTimeoutMs <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("server.connect_timeout_ms must be greater than 0"))
|
||||||
|
}
|
||||||
|
if c.Server.ResponseHeaderTimeoutMs <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("server.response_header_timeout_ms must be greater than 0"))
|
||||||
|
}
|
||||||
|
if c.Server.FirstByteTimeoutMs <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("server.first_byte_timeout_ms must be greater than 0"))
|
||||||
|
}
|
||||||
|
if c.Server.RequestReadTimeoutMs <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("server.request_read_timeout_ms must be greater than 0"))
|
||||||
|
}
|
||||||
|
if c.Server.StreamIdleTimeoutMs <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("server.stream_idle_timeout_ms must be greater than 0"))
|
||||||
|
}
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeServerTimeouts(server *Server) {
|
||||||
|
if server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.ConnectTimeoutMs = normalizeServerTimeoutMs(server.ConnectTimeoutMs, DefaultConnectTimeoutMs)
|
||||||
|
server.ResponseHeaderTimeoutMs = normalizeServerTimeoutMs(server.ResponseHeaderTimeoutMs, DefaultResponseHeaderTimeoutMs)
|
||||||
|
server.FirstByteTimeoutMs = normalizeServerTimeoutMs(server.FirstByteTimeoutMs, DefaultFirstByteTimeoutMs)
|
||||||
|
server.RequestReadTimeoutMs = normalizeServerTimeoutMs(server.RequestReadTimeoutMs, DefaultRequestReadTimeoutMs)
|
||||||
|
server.StreamIdleTimeoutMs = normalizeServerTimeoutMs(server.StreamIdleTimeoutMs, DefaultStreamIdleTimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeServerTimeoutMs(value int, fallback int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
func isLoopbackHost(host string) bool {
|
func isLoopbackHost(host string) bool {
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
if host == "" || strings.EqualFold(host, "localhost") {
|
if host == "" || strings.EqualFold(host, "localhost") {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package desktop
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -62,9 +63,27 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
a.notify.Attach(ctx)
|
a.notify.Attach(ctx)
|
||||||
a.auto.Attach(ctx)
|
a.auto.Attach(ctx)
|
||||||
_ = a.SyncDesktopPreferences(ctx)
|
_ = a.SyncDesktopPreferences(ctx)
|
||||||
|
if prefs, err := a.bindings.GetDesktopPrefs(ctx); err == nil && prefs.AutoStartProxy {
|
||||||
|
_, _ = a.bindings.StartProxy(ctx)
|
||||||
|
a.watchAutoStartProxyFailure()
|
||||||
|
}
|
||||||
a.tray.RefreshProxyStatus(ctx)
|
a.tray.RefreshProxyStatus(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) watchAutoStartProxyFailure() {
|
||||||
|
go func() {
|
||||||
|
waitCtx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
err := a.service.WaitProxy(waitCtx)
|
||||||
|
if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callCtx := a.callContext()
|
||||||
|
a.tray.RefreshProxyStatus(callCtx)
|
||||||
|
_ = a.notify.Send(callCtx, "Proxy failed to start", err.Error())
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) BeforeClose(ctx context.Context) bool {
|
func (a *App) BeforeClose(ctx context.Context) bool {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
prevent, _ := a.tray.BeforeClose(ctx)
|
prevent, _ := a.tray.BeforeClose(ctx)
|
||||||
@ -194,6 +213,18 @@ func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
|
|||||||
return a.bindings.GetProxyStatus(a.callContext())
|
return a.bindings.GetProxyStatus(a.callContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) RequestTraces(limit int) ([]app.RequestTrace, error) {
|
||||||
|
return a.bindings.ListRequestTraces(a.callContext(), limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ProxySettings() (app.ProxySettingsView, error) {
|
||||||
|
return a.bindings.GetProxySettings(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveProxySettings(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
|
||||||
|
return a.bindings.SaveProxySettings(a.callContext(), in)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) StartProxy() (app.ProxyStatusView, error) {
|
func (a *App) StartProxy() (app.ProxyStatusView, error) {
|
||||||
status, err := a.bindings.StartProxy(a.callContext())
|
status, err := a.bindings.StartProxy(a.callContext())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -89,6 +89,18 @@ func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, err
|
|||||||
return b.service.GetProxyStatus(ctx)
|
return b.service.GetProxyStatus(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ListRequestTraces(ctx context.Context, limit int) ([]app.RequestTrace, error) {
|
||||||
|
return b.service.ListRequestTraces(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) GetProxySettings(ctx context.Context) (app.ProxySettingsView, error) {
|
||||||
|
return b.service.GetProxySettings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) SaveProxySettings(ctx context.Context, in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
|
||||||
|
return b.service.SaveProxySettings(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
|
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
|
||||||
if err := b.service.StartProxy(ctx); err != nil {
|
if err := b.service.StartProxy(ctx); err != nil {
|
||||||
return app.ProxyStatusView{}, err
|
return app.ProxyStatusView{}, err
|
||||||
@ -167,6 +179,18 @@ func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
|
|||||||
return b.GetProxyStatus(context.Background())
|
return b.GetProxyStatus(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) RequestTraces(limit int) ([]app.RequestTrace, error) {
|
||||||
|
return b.ListRequestTraces(context.Background(), limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ProxySettings() (app.ProxySettingsView, error) {
|
||||||
|
return b.GetProxySettings(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) SaveProxyConfig(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
|
||||||
|
return b.SaveProxySettings(context.Background(), in)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
|
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
|
||||||
return b.StartProxy(context.Background())
|
return b.StartProxy(context.Background())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -298,6 +298,32 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
|||||||
writeResult(w, data, err)
|
writeResult(w, data, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/proxy/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
data, err := b.GetProxySettings(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
case http.MethodPost:
|
||||||
|
var in appcore.ProxySettingsInput
|
||||||
|
if !decodeJSONBody(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.SaveProxySettings(r.Context(), in)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
default:
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/proxy/traces", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.ListRequestTraces(r.Context(), 100)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
|
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
@ -76,8 +78,89 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("request traces api", func(t *testing.T) {
|
||||||
|
instance.Service().ListRequestTraces(context.Background(), 10)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/proxy/traces", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data []app.RequestTrace `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Data) != 0 {
|
||||||
|
t.Fatalf("traces = %#v, want empty list", payload.Data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("proxy settings api", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/proxy/settings", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data app.ProxySettingsView `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data.ConnectTimeoutMs != config.DefaultConnectTimeoutMs || payload.Data.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs {
|
||||||
|
t.Fatalf("proxy settings = %#v", payload.Data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("save proxy settings", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":12000,"responseHeaderTimeoutMs":21000,"firstByteTimeoutMs":22000,"requestReadTimeoutMs":33000,"streamIdleTimeoutMs":70000}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||||
|
}
|
||||||
|
loaded, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Server.ConnectTimeoutMs != 12000 || loaded.Server.ResponseHeaderTimeoutMs != 21000 || loaded.Server.FirstByteTimeoutMs != 22000 || loaded.Server.RequestReadTimeoutMs != 33000 || loaded.Server.StreamIdleTimeoutMs != 70000 {
|
||||||
|
t.Fatalf("persisted server settings = %#v", loaded.Server)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("save proxy settings normalizes non-positive values", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":0,"responseHeaderTimeoutMs":-1,"firstByteTimeoutMs":0,"requestReadTimeoutMs":-50,"streamIdleTimeoutMs":0}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data app.ProxySettingsSaveResult `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
|
||||||
|
payload.Data.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
|
||||||
|
payload.Data.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
|
||||||
|
payload.Data.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
|
||||||
|
payload.Data.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
|
||||||
|
t.Fatalf("proxy settings payload = %#v", payload.Data.Settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("save desktop prefs", func(t *testing.T) {
|
t.Run("save desktop prefs", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"autoStartProxy":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
h.ServeHTTP(resp, req)
|
h.ServeHTTP(resp, req)
|
||||||
@ -89,11 +172,148 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config.Load() error = %v", err)
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
}
|
}
|
||||||
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" {
|
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.AutoStartProxy || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" {
|
||||||
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
|
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("startup auto starts proxy when enabled", func(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = port
|
||||||
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
cfg.Desktop.AutoStartProxy = true
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tray := &spyTray{}
|
||||||
|
notify := &spyNotifier{}
|
||||||
|
originalTray := instance.tray
|
||||||
|
originalNotify := instance.notify
|
||||||
|
defer func() {
|
||||||
|
instance.tray = originalTray
|
||||||
|
instance.notify = originalNotify
|
||||||
|
_ = instance.Service().StopProxy(context.Background())
|
||||||
|
}()
|
||||||
|
instance.tray = tray
|
||||||
|
instance.notify = notify
|
||||||
|
|
||||||
|
instance.Startup(context.Background())
|
||||||
|
|
||||||
|
status, err := instance.Service().GetProxyStatus(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if !status.Running {
|
||||||
|
t.Fatalf("status = %#v, want running", status)
|
||||||
|
}
|
||||||
|
if tray.refreshCalls != 1 {
|
||||||
|
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
|
||||||
|
}
|
||||||
|
if len(notify.sends) != 0 {
|
||||||
|
t.Fatalf("startup notifications = %#v, want none", notify.sends)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("startup auto start surfaces failure without success toast", func(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = port
|
||||||
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
cfg.Desktop.AutoStartProxy = true
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tray := &spyTray{}
|
||||||
|
notify := &spyNotifier{}
|
||||||
|
originalTray := instance.tray
|
||||||
|
originalNotify := instance.notify
|
||||||
|
defer func() {
|
||||||
|
instance.tray = originalTray
|
||||||
|
instance.notify = originalNotify
|
||||||
|
_ = instance.Service().StopProxy(context.Background())
|
||||||
|
}()
|
||||||
|
instance.tray = tray
|
||||||
|
instance.notify = notify
|
||||||
|
|
||||||
|
instance.Startup(context.Background())
|
||||||
|
|
||||||
|
assertEventually(t, func() bool {
|
||||||
|
return len(notify.sends) == 1
|
||||||
|
})
|
||||||
|
|
||||||
|
status, err := instance.Service().GetProxyStatus(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
t.Fatalf("status = %#v, want stopped after failure", status)
|
||||||
|
}
|
||||||
|
if status.LastError == "" {
|
||||||
|
t.Fatalf("status = %#v, want last error", status)
|
||||||
|
}
|
||||||
|
if len(notify.sends) != 1 {
|
||||||
|
t.Fatalf("notifications = %#v, want 1 failure notice", notify.sends)
|
||||||
|
}
|
||||||
|
if notify.sends[0].Title != "Proxy failed to start" {
|
||||||
|
t.Fatalf("notification = %#v, want failure title", notify.sends[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(notify.sends[0].Body), "bind") {
|
||||||
|
t.Fatalf("notification = %#v, want bind error", notify.sends[0])
|
||||||
|
}
|
||||||
|
if tray.refreshCalls < 2 {
|
||||||
|
t.Fatalf("tray refreshCalls = %d, want refresh after failure", tray.refreshCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("proxy start api surfaces bind failure", func(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = port
|
||||||
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
cfg.Desktop.AutoStartProxy = false
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/proxy/start", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusBadRequest, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(resp.Body.String()), "bind") {
|
||||||
|
t.Fatalf("body = %q, want bind error", resp.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("save desktop prefs keeps success with integration warning", func(t *testing.T) {
|
t.Run("save desktop prefs keeps success with integration warning", func(t *testing.T) {
|
||||||
originalAuto := instance.auto
|
originalAuto := instance.auto
|
||||||
instance.auto = failingAutoStart{message: "startup folder unavailable"}
|
instance.auto = failingAutoStart{message: "startup folder unavailable"}
|
||||||
@ -407,6 +627,7 @@ func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
|
|||||||
type spyNotifier struct {
|
type spyNotifier struct {
|
||||||
syncCalls int
|
syncCalls int
|
||||||
lastPrefs app.DesktopPrefsView
|
lastPrefs app.DesktopPrefsView
|
||||||
|
sends []notifyCall
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spyNotifier) Attach(_ context.Context) {}
|
func (s *spyNotifier) Attach(_ context.Context) {}
|
||||||
@ -418,6 +639,34 @@ func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
|
|||||||
s.lastPrefs = prefs
|
s.lastPrefs = prefs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spyNotifier) Send(_ context.Context, _ string, _ string) error {
|
func (s *spyNotifier) Send(_ context.Context, title string, body string) error {
|
||||||
|
s.sends = append(s.sends, notifyCall{Title: title, Body: body})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type notifyCall struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEventually(t *testing.T, fn func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if fn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("condition not satisfied before timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
return listener.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|||||||
@ -18,10 +18,6 @@ import (
|
|||||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var firstByteTimeout = 15 * time.Second
|
|
||||||
var requestReadTimeout = 30 * time.Second
|
|
||||||
var streamIdleTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
type openAIErrorEnvelope struct {
|
type openAIErrorEnvelope struct {
|
||||||
Error openAIError `json:"error"`
|
Error openAIError `json:"error"`
|
||||||
}
|
}
|
||||||
@ -43,21 +39,31 @@ type Server struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
client *http.Client
|
client *http.Client
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
traces *TraceStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Server from cfg.
|
// New constructs a Server from cfg.
|
||||||
func New(cfg *config.Config) *Server {
|
func New(cfg *config.Config, stores ...*TraceStore) *Server {
|
||||||
|
var traces *TraceStore
|
||||||
|
if len(stores) > 0 {
|
||||||
|
traces = stores[0]
|
||||||
|
}
|
||||||
|
if traces == nil {
|
||||||
|
traces = NewTraceStore(defaultTraceLimit)
|
||||||
|
}
|
||||||
|
firstByteTimeout := timeoutDuration(cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||||
|
responseHeaderTimeout := timeoutDuration(cfg.Server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
Proxy: http.ProxyFromEnvironment,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
}).DialContext,
|
}).DialContext,
|
||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
ResponseHeaderTimeout: firstByteTimeout,
|
ResponseHeaderTimeout: minDuration(responseHeaderTimeout, firstByteTimeout),
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
}
|
}
|
||||||
@ -68,11 +74,18 @@ func New(cfg *config.Config) *Server {
|
|||||||
Timeout: 0,
|
Timeout: 0,
|
||||||
},
|
},
|
||||||
logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
|
logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
|
||||||
|
traces: traces,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenAndServe starts the HTTP listener until ctx is cancelled.
|
// ListenAndServe starts the HTTP listener until ctx is cancelled.
|
||||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||||
|
return s.ListenAndServeWithReady(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServeWithReady starts the HTTP listener until ctx is cancelled and
|
||||||
|
// reports whether the listening socket was bound successfully.
|
||||||
|
func (s *Server) ListenAndServeWithReady(ctx context.Context, ready chan<- error) error {
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/v1/responses", s.handleResponses)
|
mux.HandleFunc("/v1/responses", s.handleResponses)
|
||||||
@ -81,14 +94,21 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if ready != nil {
|
||||||
|
ready <- err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: requestReadTimeout,
|
ReadTimeout: timeoutDuration(s.cfg.Server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
|
||||||
}
|
}
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() { errCh <- srv.ListenAndServe() }()
|
go func() { errCh <- srv.Serve(listener) }()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@ -142,6 +162,7 @@ var reqCounter uint64
|
|||||||
// handleResponses is the main alias→failover proxy entry.
|
// handleResponses is the main alias→failover proxy entry.
|
||||||
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||||
reqID := atomic.AddUint64(&reqCounter, 1)
|
reqID := atomic.AddUint64(&reqCounter, 1)
|
||||||
|
startedAt := time.Now()
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
return
|
return
|
||||||
@ -168,21 +189,50 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rawModel := aliasName
|
rawModel := aliasName
|
||||||
aliasName = normalizeAliasName(aliasName)
|
aliasName = normalizeAliasName(aliasName)
|
||||||
|
trace := RequestTrace{
|
||||||
|
ID: reqID,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
RawModel: rawModel,
|
||||||
|
Alias: aliasName,
|
||||||
|
RequestHeaders: sanitizeHeaderMap(r.Header),
|
||||||
|
RequestParams: sanitizeJSONValue("", payload),
|
||||||
|
}
|
||||||
|
if stream, ok := payload["stream"].(bool); ok {
|
||||||
|
trace.Stream = stream
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
trace.FinishedAt = time.Now()
|
||||||
|
trace.DurationMs = trace.FinishedAt.Sub(trace.StartedAt).Milliseconds()
|
||||||
|
trace.AttemptCount = len(trace.Attempts)
|
||||||
|
trace.Failover = len(trace.Attempts) > 1
|
||||||
|
if trace.FirstByteMs == 0 {
|
||||||
|
for _, attempt := range trace.Attempts {
|
||||||
|
if attempt.FirstByteMs > 0 {
|
||||||
|
trace.FirstByteMs = attempt.FirstByteMs
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.traces.Add(trace)
|
||||||
|
}()
|
||||||
s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"])
|
s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"])
|
||||||
alias := s.cfg.FindAlias(aliasName)
|
alias := s.cfg.FindAlias(aliasName)
|
||||||
if alias == nil {
|
if alias == nil {
|
||||||
s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName)
|
s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName)
|
||||||
|
trace.Error = fmt.Sprintf("alias %q not found", aliasName)
|
||||||
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName))
|
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !alias.Enabled {
|
if !alias.Enabled {
|
||||||
s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName)
|
s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName)
|
||||||
|
trace.Error = fmt.Sprintf("alias %q is disabled", aliasName)
|
||||||
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
|
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
targets := s.cfg.AvailableTargets(*alias)
|
targets := s.cfg.AvailableTargets(*alias)
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName)
|
s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName)
|
||||||
|
trace.Error = fmt.Sprintf("alias %q has no available targets", aliasName)
|
||||||
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName))
|
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -190,28 +240,62 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||||||
failoverCount := 0
|
failoverCount := 0
|
||||||
var lastRetryable *upstreamFailure
|
var lastRetryable *upstreamFailure
|
||||||
for attempt, t := range targets {
|
for attempt, t := range targets {
|
||||||
|
attemptTrace := TraceAttempt{
|
||||||
|
Attempt: attempt + 1,
|
||||||
|
Provider: t.Provider,
|
||||||
|
Model: t.Model,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Result: "pending",
|
||||||
|
}
|
||||||
p := s.cfg.FindProvider(t.Provider)
|
p := s.cfg.FindProvider(t.Provider)
|
||||||
if p == nil || !p.IsEnabled() {
|
if p == nil || !p.IsEnabled() {
|
||||||
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider)
|
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider)
|
||||||
|
attemptTrace.Skipped = true
|
||||||
|
attemptTrace.Result = "skipped"
|
||||||
|
attemptTrace.Error = fmt.Sprintf("provider %q unavailable", t.Provider)
|
||||||
|
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
||||||
|
trace.Attempts = append(trace.Attempts, attemptTrace)
|
||||||
failoverCount++
|
failoverCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount)
|
s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount)
|
||||||
cloned := cloneMap(payload)
|
cloned := cloneMap(payload)
|
||||||
cloned["model"] = t.Model
|
cloned["model"] = t.Model
|
||||||
|
upstreamURL := strings.TrimRight(p.BaseURL, "/") + "/responses"
|
||||||
|
attemptTrace.URL = upstreamURL
|
||||||
|
attemptTrace.RequestParams = sanitizeJSONValue("", cloned)
|
||||||
newBody, err := json.Marshal(cloned)
|
newBody, err := json.Marshal(cloned)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("req=%d marshal error: %v", reqID, err)
|
s.logger.Printf("req=%d marshal error: %v", reqID, err)
|
||||||
|
attemptTrace.Result = "internal_error"
|
||||||
|
attemptTrace.Error = "marshal error"
|
||||||
|
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
||||||
|
trace.Attempts = append(trace.Attempts, attemptTrace)
|
||||||
|
trace.Error = "marshal error"
|
||||||
writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error")
|
writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount)
|
handled, success, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount, &attemptTrace, &trace)
|
||||||
if ok {
|
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
||||||
|
trace.Attempts = append(trace.Attempts, attemptTrace)
|
||||||
|
trace.FinalProvider = p.ID
|
||||||
|
trace.FinalModel = t.Model
|
||||||
|
trace.FinalURL = upstreamURL
|
||||||
|
trace.StatusCode = attemptTrace.StatusCode
|
||||||
|
if trace.FirstByteMs == 0 {
|
||||||
|
trace.FirstByteMs = attemptTrace.FirstByteMs
|
||||||
|
}
|
||||||
|
if handled {
|
||||||
|
trace.Success = success
|
||||||
|
if !success {
|
||||||
|
trace.Error = errorString(upstreamErr)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !retryable {
|
if !retryable {
|
||||||
s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr)
|
s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr)
|
||||||
|
trace.Error = errorString(upstreamErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
@ -222,6 +306,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if lastRetryable != nil {
|
if lastRetryable != nil {
|
||||||
|
trace.StatusCode = lastRetryable.status
|
||||||
|
trace.Error = fmt.Sprintf("upstream %d", lastRetryable.status)
|
||||||
copyResponseHeaders(w.Header(), lastRetryable.header)
|
copyResponseHeaders(w.Header(), lastRetryable.header)
|
||||||
w.WriteHeader(lastRetryable.status)
|
w.WriteHeader(lastRetryable.status)
|
||||||
if len(lastRetryable.body) > 0 {
|
if len(lastRetryable.body) > 0 {
|
||||||
@ -230,11 +316,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trace.StatusCode = http.StatusBadGateway
|
||||||
|
trace.Error = fmt.Sprintf("all upstream targets failed for alias %q", aliasName)
|
||||||
writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName))
|
writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName))
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryOnce proxies one attempt. Returns (ok, retryable, err, failure).
|
// tryOnce proxies one attempt. Returns (handled, success, retryable, err, failure).
|
||||||
// ok=true means successful response fully/partially written to client.
|
// handled=true means a downstream response has already been started or completed.
|
||||||
// retryable=true means failure happened before any bytes flushed downstream.
|
// retryable=true means failure happened before any bytes flushed downstream.
|
||||||
func (s *Server) tryOnce(
|
func (s *Server) tryOnce(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@ -246,14 +334,16 @@ func (s *Server) tryOnce(
|
|||||||
aliasName string,
|
aliasName string,
|
||||||
attempt int,
|
attempt int,
|
||||||
failoverCount int,
|
failoverCount int,
|
||||||
) (ok bool, retryable bool, err error, failure *upstreamFailure) {
|
attemptTrace *TraceAttempt,
|
||||||
|
trace *RequestTrace,
|
||||||
|
) (handled bool, success bool, retryable bool, err error, failure *upstreamFailure) {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses"
|
upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses"
|
||||||
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
|
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, fmt.Errorf("build request: %w", err), nil
|
return false, false, false, fmt.Errorf("build request: %w", err), nil
|
||||||
}
|
}
|
||||||
copyForwardHeaders(upReq.Header, clientReq.Header)
|
copyForwardHeaders(upReq.Header, clientReq.Header)
|
||||||
upReq.Header.Set("Content-Type", "application/json")
|
upReq.Header.Set("Content-Type", "application/json")
|
||||||
@ -265,46 +355,95 @@ func (s *Server) tryOnce(
|
|||||||
upReq.Header.Set(k, v)
|
upReq.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
upReq.ContentLength = int64(len(body))
|
upReq.ContentLength = int64(len(body))
|
||||||
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.RequestHeaders = sanitizeHeaderMap(upReq.Header)
|
||||||
|
}
|
||||||
|
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
|
firstByteTimeout := timeoutDuration(s.cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||||
resp, err := s.client.Do(upReq)
|
resp, err := s.client.Do(upReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Retryable = true
|
||||||
|
attemptTrace.Result = "transport_error"
|
||||||
|
attemptTrace.Error = fmt.Sprintf("upstream dial/transport: %v", err)
|
||||||
|
}
|
||||||
|
return false, false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.StatusCode = resp.StatusCode
|
||||||
|
attemptTrace.ResponseHeaders = sanitizeHeaderMap(resp.Header)
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
|
||||||
failure = captureRetryableFailure(resp)
|
failure = captureRetryableFailure(resp)
|
||||||
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure
|
sanitizedBody := sanitizeResponseBody(resp.Header.Get("Content-Type"), failure.body)
|
||||||
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Retryable = true
|
||||||
|
attemptTrace.Result = "retryable_failure"
|
||||||
|
attemptTrace.Error = fmt.Sprintf("upstream %d: %s", resp.StatusCode, sanitizedBody)
|
||||||
|
attemptTrace.ResponseBody = sanitizedBody
|
||||||
|
}
|
||||||
|
return false, false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, sanitizedBody), failure
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
|
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
|
||||||
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Result = "final_failure"
|
||||||
|
}
|
||||||
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
|
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
|
||||||
copyResponseHeaders(w.Header(), resp.Header)
|
copyResponseHeaders(w.Header(), resp.Header)
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
_, _ = io.Copy(w, resp.Body)
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.ResponseBody = sanitizeResponseBody(resp.Header.Get("Content-Type"), bodyBytes)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(bodyBytes)
|
||||||
|
return true, false, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := firstByteTimeout - time.Since(startedAt)
|
remaining := firstByteTimeout - time.Since(startedAt)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||||
}
|
}
|
||||||
firstChunk, firstErr := readFirstChunk(resp.Body, remaining)
|
firstChunk, firstErr := readFirstChunk(resp.Body, remaining)
|
||||||
if firstErr != nil {
|
if firstErr != nil {
|
||||||
if errors.Is(firstErr, errFirstByteTimeout) {
|
if errors.Is(firstErr, errFirstByteTimeout) {
|
||||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Retryable = true
|
||||||
|
attemptTrace.Result = "first_byte_timeout"
|
||||||
|
attemptTrace.Error = fmt.Sprintf("upstream first byte timeout after %s", firstByteTimeout)
|
||||||
|
}
|
||||||
|
return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||||
}
|
}
|
||||||
if errors.Is(firstErr, io.EOF) {
|
if errors.Is(firstErr, io.EOF) {
|
||||||
if len(firstChunk) == 0 {
|
if len(firstChunk) == 0 {
|
||||||
return false, true, fmt.Errorf("upstream closed before first byte"), nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Retryable = true
|
||||||
|
attemptTrace.Result = "empty_response"
|
||||||
|
attemptTrace.Error = "upstream closed before first byte"
|
||||||
|
}
|
||||||
|
return false, false, true, fmt.Errorf("upstream closed before first byte"), nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Retryable = true
|
||||||
|
attemptTrace.Result = "first_read_error"
|
||||||
|
attemptTrace.Error = fmt.Sprintf("upstream first read: %v", firstErr)
|
||||||
|
}
|
||||||
|
return false, false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.FirstByteMs = time.Since(startedAt).Milliseconds()
|
||||||
|
}
|
||||||
|
if trace != nil && trace.FirstByteMs == 0 && attemptTrace != nil {
|
||||||
|
trace.FirstByteMs = attemptTrace.FirstByteMs
|
||||||
|
}
|
||||||
|
|
||||||
isEventStream := false
|
isEventStream := false
|
||||||
|
streamIdleTimeout := timeoutDuration(s.cfg.Server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
|
||||||
if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil {
|
if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil {
|
||||||
isEventStream = mediaType == "text/event-stream"
|
isEventStream = mediaType == "text/event-stream"
|
||||||
}
|
}
|
||||||
@ -316,7 +455,11 @@ func (s *Server) tryOnce(
|
|||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
if len(firstChunk) > 0 {
|
if len(firstChunk) > 0 {
|
||||||
if _, werr := w.Write(firstChunk); werr != nil {
|
if _, werr := w.Write(firstChunk); werr != nil {
|
||||||
return true, false, werr, nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Result = "downstream_write_error"
|
||||||
|
attemptTrace.Error = werr.Error()
|
||||||
|
}
|
||||||
|
return true, false, false, werr, nil
|
||||||
}
|
}
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@ -335,7 +478,11 @@ func (s *Server) tryOnce(
|
|||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||||
return true, false, werr, nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Result = "downstream_write_error"
|
||||||
|
attemptTrace.Error = werr.Error()
|
||||||
|
}
|
||||||
|
return true, false, false, werr, nil
|
||||||
}
|
}
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@ -343,14 +490,29 @@ func (s *Server) tryOnce(
|
|||||||
}
|
}
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
if errors.Is(rerr, io.EOF) {
|
if errors.Is(rerr, io.EOF) {
|
||||||
return true, false, nil, nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Result = "success"
|
||||||
|
attemptTrace.Success = true
|
||||||
|
}
|
||||||
|
return true, true, false, nil, nil
|
||||||
}
|
}
|
||||||
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream body read failed after response start: %v", aliasName, attempt, provider.ID, target.Model, rerr)
|
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream body read failed after response start: %v", aliasName, attempt, provider.ID, target.Model, rerr)
|
||||||
return true, false, rerr, nil
|
if attemptTrace != nil {
|
||||||
|
attemptTrace.Result = "stream_error"
|
||||||
|
attemptTrace.Error = rerr.Error()
|
||||||
|
}
|
||||||
|
return true, false, false, rerr, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func errorString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
var errFirstByteTimeout = errors.New("first byte timeout")
|
var errFirstByteTimeout = errors.New("first byte timeout")
|
||||||
var errStreamIdleTimeout = errors.New("stream idle timeout")
|
var errStreamIdleTimeout = errors.New("stream idle timeout")
|
||||||
|
|
||||||
@ -448,6 +610,20 @@ func requestReadError(err error) (int, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func timeoutDuration(value int, fallback int) time.Duration {
|
||||||
|
if value <= 0 {
|
||||||
|
value = fallback
|
||||||
|
}
|
||||||
|
return time.Duration(value) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func minDuration(a time.Duration, b time.Duration) time.Duration {
|
||||||
|
if a <= b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAliasName(model string) string {
|
func normalizeAliasName(model string) string {
|
||||||
prefix := config.AppName + "/"
|
prefix := config.AppName + "/"
|
||||||
if strings.HasPrefix(model, prefix) {
|
if strings.HasPrefix(model, prefix) {
|
||||||
|
|||||||
@ -105,6 +105,16 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
|||||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
||||||
}
|
}
|
||||||
|
traces := srv.traces.List(10)
|
||||||
|
if len(traces) != 1 {
|
||||||
|
t.Fatalf("trace count = %d, want 1", len(traces))
|
||||||
|
}
|
||||||
|
if !traces[0].Failover || traces[0].FinalProvider != "p2" || traces[0].AttemptCount != 2 {
|
||||||
|
t.Fatalf("trace = %#v", traces[0])
|
||||||
|
}
|
||||||
|
if got := traces[0].RequestHeaders["Authorization"]; got == "Bearer "+config.DefaultLocalAPIKey || got == "" {
|
||||||
|
t.Fatalf("trace auth header = %q, want masked value", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
|
func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
|
||||||
@ -156,10 +166,6 @@ func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
|
func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
|
||||||
oldTimeout := streamIdleTimeout
|
|
||||||
streamIdleTimeout = 30 * time.Millisecond
|
|
||||||
defer func() { streamIdleTimeout = oldTimeout }()
|
|
||||||
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@ -176,7 +182,7 @@ func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
|
|||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
|
|
||||||
srv := New(&config.Config{
|
srv := New(&config.Config{
|
||||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
Server: config.Server{APIKey: config.DefaultLocalAPIKey, StreamIdleTimeoutMs: 30},
|
||||||
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
|
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
|
||||||
Aliases: []config.Alias{{
|
Aliases: []config.Alias{{
|
||||||
Alias: "gpt-5.4",
|
Alias: "gpt-5.4",
|
||||||
@ -201,6 +207,87 @@ func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleResponsesMarksBrokenStreamAsFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("data: first\n\n"))
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
hijacker, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("response writer does not support hijacking")
|
||||||
|
}
|
||||||
|
conn, _, err := hijacker.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hijack() error = %v", err)
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
srv := New(&config.Config{
|
||||||
|
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||||
|
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
|
||||||
|
Aliases: []config.Alias{{
|
||||||
|
Alias: "gpt-5.4",
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.handleResponses(rr, req)
|
||||||
|
|
||||||
|
traces := srv.traces.List(10)
|
||||||
|
if len(traces) != 1 {
|
||||||
|
t.Fatalf("trace count = %d, want 1", len(traces))
|
||||||
|
}
|
||||||
|
if traces[0].Success {
|
||||||
|
t.Fatalf("trace = %#v, want failed trace", traces[0])
|
||||||
|
}
|
||||||
|
if traces[0].Error == "" {
|
||||||
|
t.Fatalf("trace = %#v, want error", traces[0])
|
||||||
|
}
|
||||||
|
if len(traces[0].Attempts) != 1 {
|
||||||
|
t.Fatalf("attempts = %#v, want 1", traces[0].Attempts)
|
||||||
|
}
|
||||||
|
if traces[0].Attempts[0].Success {
|
||||||
|
t.Fatalf("attempt = %#v, want failed attempt", traces[0].Attempts[0])
|
||||||
|
}
|
||||||
|
if traces[0].Attempts[0].Result != "stream_error" {
|
||||||
|
t.Fatalf("attempt result = %q, want stream_error", traces[0].Attempts[0].Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUsesConfiguredTimeouts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := New(&config.Config{Server: config.Server{
|
||||||
|
ConnectTimeoutMs: 12000,
|
||||||
|
ResponseHeaderTimeoutMs: 21000,
|
||||||
|
FirstByteTimeoutMs: 22000,
|
||||||
|
RequestReadTimeoutMs: 33000,
|
||||||
|
StreamIdleTimeoutMs: 70000,
|
||||||
|
}})
|
||||||
|
|
||||||
|
transport, ok := srv.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport type = %T", srv.client.Transport)
|
||||||
|
}
|
||||||
|
if transport.ResponseHeaderTimeout != 21*time.Second {
|
||||||
|
t.Fatalf("ResponseHeaderTimeout = %s, want 21s", transport.ResponseHeaderTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
260
internal/proxy/traces.go
Normal file
260
internal/proxy/traces.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultTraceLimit = 200
|
||||||
|
|
||||||
|
type TraceStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
limit int
|
||||||
|
traces []RequestTrace
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestTrace struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||||
|
RawModel string `json:"rawModel,omitempty"`
|
||||||
|
Alias string `json:"alias,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
FinalProvider string `json:"finalProvider,omitempty"`
|
||||||
|
FinalModel string `json:"finalModel,omitempty"`
|
||||||
|
FinalURL string `json:"finalUrl,omitempty"`
|
||||||
|
Failover bool `json:"failover"`
|
||||||
|
AttemptCount int `json:"attemptCount"`
|
||||||
|
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||||
|
RequestParams any `json:"requestParams,omitempty"`
|
||||||
|
Attempts []TraceAttempt `json:"attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceAttempt struct {
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||||
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||||
|
RequestParams any `json:"requestParams,omitempty"`
|
||||||
|
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
|
||||||
|
ResponseBody string `json:"responseBody,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTraceStore(limit int) *TraceStore {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultTraceLimit
|
||||||
|
}
|
||||||
|
return &TraceStore{limit: limit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceStore) Add(trace RequestTrace) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
clone := cloneTrace(trace)
|
||||||
|
s.traces = append([]RequestTrace{clone}, s.traces...)
|
||||||
|
if len(s.traces) > s.limit {
|
||||||
|
s.traces = s.traces[:s.limit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceStore) List(limit int) []RequestTrace {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if limit <= 0 || limit > len(s.traces) {
|
||||||
|
limit = len(s.traces)
|
||||||
|
}
|
||||||
|
out := make([]RequestTrace, 0, limit)
|
||||||
|
for _, trace := range s.traces[:limit] {
|
||||||
|
out = append(out, cloneTrace(trace))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTrace(trace RequestTrace) RequestTrace {
|
||||||
|
trace.RequestHeaders = cloneStringMap(trace.RequestHeaders)
|
||||||
|
trace.RequestParams = cloneJSONValue(trace.RequestParams)
|
||||||
|
if len(trace.Attempts) == 0 {
|
||||||
|
trace.Attempts = []TraceAttempt{}
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
trace.Attempts = append([]TraceAttempt(nil), trace.Attempts...)
|
||||||
|
for index := range trace.Attempts {
|
||||||
|
trace.Attempts[index].RequestHeaders = cloneStringMap(trace.Attempts[index].RequestHeaders)
|
||||||
|
trace.Attempts[index].RequestParams = cloneJSONValue(trace.Attempts[index].RequestParams)
|
||||||
|
trace.Attempts[index].ResponseHeaders = cloneStringMap(trace.Attempts[index].ResponseHeaders)
|
||||||
|
}
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(in map[string]string) map[string]string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(in))
|
||||||
|
for key, value := range in {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneJSONValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(typed))
|
||||||
|
for key, nested := range typed {
|
||||||
|
out[key] = cloneJSONValue(nested)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(typed))
|
||||||
|
for index, nested := range typed {
|
||||||
|
out[index] = cloneJSONValue(nested)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sensitiveHeaderNames = map[string]bool{
|
||||||
|
"authorization": true,
|
||||||
|
"proxy-authorization": true,
|
||||||
|
"x-api-key": true,
|
||||||
|
"api-key": true,
|
||||||
|
"cookie": true,
|
||||||
|
"set-cookie": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var redactedPayloadKeys = map[string]bool{
|
||||||
|
"content": true,
|
||||||
|
"input": true,
|
||||||
|
"instructions": true,
|
||||||
|
"messages": true,
|
||||||
|
"output": true,
|
||||||
|
"output_text": true,
|
||||||
|
"prompt": true,
|
||||||
|
"response": true,
|
||||||
|
"text": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeHeaderMap(header http.Header) map[string]string {
|
||||||
|
if len(header) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(header))
|
||||||
|
for key := range header {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
values := header.Values(key)
|
||||||
|
joined := strings.Join(values, ", ")
|
||||||
|
if sensitiveHeaderNames[strings.ToLower(key)] {
|
||||||
|
joined = maskSensitiveValue(joined)
|
||||||
|
}
|
||||||
|
out[key] = joined
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeJSONValue(key string, value any) any {
|
||||||
|
if redactedPayloadKeys[strings.ToLower(strings.TrimSpace(key))] {
|
||||||
|
return redactedSummary(value)
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(typed))
|
||||||
|
keys := make([]string, 0, len(typed))
|
||||||
|
for nestedKey := range typed {
|
||||||
|
keys = append(keys, nestedKey)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, nestedKey := range keys {
|
||||||
|
out[nestedKey] = sanitizeJSONValue(nestedKey, typed[nestedKey])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(typed))
|
||||||
|
for index, nested := range typed {
|
||||||
|
out[index] = sanitizeJSONValue("", nested)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactedSummary(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
return fmt.Sprintf("<redacted %d item(s)>", len(typed))
|
||||||
|
case map[string]any:
|
||||||
|
return fmt.Sprintf("<redacted object with %d key(s)>", len(typed))
|
||||||
|
case string:
|
||||||
|
if typed == "" {
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<redacted %d chars>", len(typed))
|
||||||
|
default:
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeResponseBody(contentType string, body []byte) string {
|
||||||
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(contentType), "json") {
|
||||||
|
var payload any
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &payload); err == nil {
|
||||||
|
sanitized := sanitizeJSONValue("", payload)
|
||||||
|
encoded, err := json.Marshal(sanitized)
|
||||||
|
if err == nil {
|
||||||
|
return truncate(string(encoded), 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if redacted, ok := redactedSummary(trimmed).(string); ok {
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskSensitiveValue(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
if len(trimmed) <= 8 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return trimmed[:4] + "..." + trimmed[len(trimmed)-4:]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user