fix(desktop): stabilize proxy and trace flows
This commit is contained in:
parent
bbe21e14df
commit
a1eb4e244c
1127
frontend/src/App.tsx
1127
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,10 @@ import type {
|
||||
ProviderStateInput,
|
||||
ProviderUpsertInput,
|
||||
ProviderView,
|
||||
ProxySettingsSaveResult,
|
||||
ProxySettingsView,
|
||||
ProxyStatusView,
|
||||
RequestTrace,
|
||||
SyncInput,
|
||||
SyncPreview,
|
||||
SyncResult,
|
||||
@ -157,6 +160,20 @@ export function getProxyStatus(): Promise<ProxyStatusView> {
|
||||
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> {
|
||||
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>
|
||||
DoctorRun: () => Promise<import('./types').DoctorRunResult>
|
||||
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
||||
ProxySettings: () => Promise<import('./types').ProxySettingsView>
|
||||
RequestTraces: (limit: number) => Promise<import('./types').RequestTrace[]>
|
||||
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
||||
SaveProxySettings: (input: import('./types').ProxySettingsView) => Promise<import('./types').ProxySettingsSaveResult>
|
||||
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
||||
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
||||
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
"overview": { "title": "Overview", "description": "Status, actions, and environment" },
|
||||
"providers": { "title": "Providers", "description": "Manage upstream endpoints" },
|
||||
"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" },
|
||||
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
|
||||
},
|
||||
@ -32,7 +34,6 @@
|
||||
"unbind": "Unbind",
|
||||
"newProvider": "New provider",
|
||||
"newAlias": "New alias",
|
||||
"useInBindForm": "Use in bind form",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
@ -112,6 +113,9 @@
|
||||
"filterEnabled": "Enabled",
|
||||
"filterDisabled": "Disabled",
|
||||
"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.",
|
||||
"emptyHint": "Create one manually or import providers from your OpenCode config.",
|
||||
"noMatches": "No providers match the current filters.",
|
||||
@ -159,6 +163,9 @@
|
||||
"search": "Search aliases",
|
||||
"searchPlaceholder": "Filter by alias, display name, provider, or model",
|
||||
"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.",
|
||||
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
|
||||
"noMatches": "No aliases match the current filters.",
|
||||
@ -209,12 +216,51 @@
|
||||
"debugTitle": "Raw doctor JSON",
|
||||
"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": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Persist desktop behavior, theme preference, and language preference.",
|
||||
"appearanceTitle": "Appearance",
|
||||
"languageTitle": "Language",
|
||||
"behaviorTitle": "Desktop behavior",
|
||||
"timeoutTitle": "Proxy timeouts",
|
||||
"configTitle": "Config file",
|
||||
"importTitle": "Import mode",
|
||||
"importModeText": "Import text",
|
||||
@ -241,6 +287,15 @@
|
||||
"resolvedTheme": "Resolved theme",
|
||||
"resolvedLanguage": "Resolved language",
|
||||
"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",
|
||||
"notifications": "Native notifications"
|
||||
},
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
"overview": { "title": "概览", "description": "状态、操作与环境信息" },
|
||||
"providers": { "title": "提供商", "description": "管理上游端点" },
|
||||
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
|
||||
"log": { "title": "日志", "description": "查看业务路由、alias 决策与 failover 链路" },
|
||||
"network": { "title": "网络", "description": "查看除 LLM 输入输出内容外的全部请求元数据" },
|
||||
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
|
||||
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
|
||||
},
|
||||
@ -32,7 +34,6 @@
|
||||
"unbind": "解绑",
|
||||
"newProvider": "新建提供商",
|
||||
"newAlias": "新建别名",
|
||||
"useInBindForm": "带入绑定表单",
|
||||
"close": "关闭",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
@ -112,6 +113,9 @@
|
||||
"filterEnabled": "已启用",
|
||||
"filterDisabled": "已禁用",
|
||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||
"detailHint": "左侧选中一行后,在右侧查看并编辑详情。",
|
||||
"selectTitle": "选择一个提供商",
|
||||
"selectHint": "左侧列表已压缩为高密度模式,适合首屏浏览更多条目;选中一行后在这里查看或编辑详情。",
|
||||
"empty": "还没有配置任何提供商。",
|
||||
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
|
||||
"noMatches": "当前筛选条件下没有匹配的提供商。",
|
||||
@ -159,6 +163,9 @@
|
||||
"search": "搜索别名",
|
||||
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
|
||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||
"detailHint": "左侧选中一行后,在右侧查看别名设置和目标详情。",
|
||||
"selectTitle": "选择一个别名",
|
||||
"selectHint": "左侧列表保持紧凑,方便一屏浏览更多路由;选中一行后在这里查看或编辑详情。",
|
||||
"empty": "还没有配置任何别名。",
|
||||
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
|
||||
"noMatches": "当前筛选条件下没有匹配的别名。",
|
||||
@ -209,12 +216,51 @@
|
||||
"debugTitle": "原始 Doctor JSON",
|
||||
"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": {
|
||||
"title": "设置",
|
||||
"subtitle": "持久化桌面行为、主题偏好和语言偏好。",
|
||||
"appearanceTitle": "外观",
|
||||
"languageTitle": "语言",
|
||||
"behaviorTitle": "桌面行为",
|
||||
"timeoutTitle": "代理超时",
|
||||
"configTitle": "配置文件",
|
||||
"importTitle": "导入方式",
|
||||
"importModeText": "文本导入",
|
||||
@ -241,6 +287,15 @@
|
||||
"resolvedTheme": "实际主题",
|
||||
"resolvedLanguage": "实际语言",
|
||||
"launchAtLogin": "开机启动",
|
||||
"autoStartProxy": "启动应用时自动开启代理",
|
||||
"connectTimeout": "连接超时(毫秒)",
|
||||
"responseHeaderTimeout": "响应头超时(毫秒)",
|
||||
"firstByteTimeout": "首字节超时(毫秒)",
|
||||
"requestReadTimeout": "客户端请求读取超时(毫秒)",
|
||||
"streamIdleTimeout": "流空闲超时(毫秒)",
|
||||
"timeoutHint": "保存后会在下一次启动代理时生效。",
|
||||
"timeoutRunningHint": "代理当前正在运行,保存后的值要在重启代理后才会生效。",
|
||||
"saveTimeouts": "保存代理超时",
|
||||
"minimizeToTray": "最小化到托盘 / 关闭时转入后台",
|
||||
"notifications": "系统通知"
|
||||
},
|
||||
|
||||
@ -263,8 +263,7 @@ html[data-theme='dark'] .sidebar {
|
||||
|
||||
.sidebar-brand,
|
||||
.panel,
|
||||
.subpanel,
|
||||
.workspace-header {
|
||||
.subpanel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
@ -304,7 +303,6 @@ html[data-theme='dark'] .sidebar {
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-kicker,
|
||||
.meta-label,
|
||||
.stat-label,
|
||||
dt {
|
||||
@ -316,7 +314,6 @@ dt {
|
||||
}
|
||||
|
||||
.sidebar-brand h1,
|
||||
.workspace-header h2,
|
||||
.panel h3,
|
||||
.subpanel h4 {
|
||||
margin: 0;
|
||||
@ -326,10 +323,6 @@ dt {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.workspace-header h2 {
|
||||
font-size: clamp(1.85rem, 2vw, 2.45rem);
|
||||
}
|
||||
|
||||
.subtle {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
@ -388,7 +381,6 @@ html[data-theme='dark'] .nav-item.active {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .nav-item.active .nav-description,
|
||||
html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
color: #08111f;
|
||||
}
|
||||
@ -399,13 +391,8 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.toolbar,
|
||||
.hero-actions,
|
||||
.item-heading,
|
||||
.subpanel-header,
|
||||
.list-toolbar {
|
||||
@ -423,8 +410,7 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
.stack,
|
||||
.stack-blocks,
|
||||
.issue-stack,
|
||||
.target-list,
|
||||
.hero-meta {
|
||||
.target-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@ -433,7 +419,6 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
.target-card,
|
||||
.item-card,
|
||||
.stat-card,
|
||||
.hero-meta-card,
|
||||
.inline-meta {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
@ -448,75 +433,6 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@ -528,10 +444,15 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
}
|
||||
|
||||
.sync-layout,
|
||||
.trace-layout,
|
||||
.settings-layout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.trace-layout {
|
||||
grid-template-columns: minmax(300px, 0.78fr) minmax(0, 1.22fr);
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr);
|
||||
align-items: start;
|
||||
@ -552,6 +473,85 @@ html[data-theme='dark'] .nav-item.active .nav-title {
|
||||
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,
|
||||
.action-grid,
|
||||
.info-grid,
|
||||
@ -681,6 +681,11 @@ html[data-theme='dark'] button.ghost-danger:hover {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.providers-layout,
|
||||
.aliases-layout {
|
||||
grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.08fr);
|
||||
}
|
||||
|
||||
.editor-column,
|
||||
.list-column {
|
||||
min-width: 0;
|
||||
@ -717,6 +722,78 @@ html[data-theme='dark'] button.ghost-danger:hover {
|
||||
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 {
|
||||
width: 10px;
|
||||
}
|
||||
@ -1108,15 +1185,14 @@ dd {
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.nav-description {
|
||||
display: none;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-layout,
|
||||
.sync-layout,
|
||||
.trace-layout,
|
||||
.providers-layout,
|
||||
.aliases-layout,
|
||||
.stats-grid,
|
||||
.card-grid-list,
|
||||
.info-grid,
|
||||
@ -1139,9 +1215,7 @@ dd {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspace-header,
|
||||
.panel-header,
|
||||
.hero-actions,
|
||||
.toolbar,
|
||||
.card-actions,
|
||||
.subpanel-header,
|
||||
@ -1150,7 +1224,7 @@ dd {
|
||||
.target-card-main,
|
||||
.item-heading,
|
||||
.item-summary,
|
||||
.hero-actions-wrap,
|
||||
.compact-row-main,
|
||||
.inline-meta {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@ -1164,6 +1238,7 @@ dd {
|
||||
.nav-list,
|
||||
.overview-layout,
|
||||
.sync-layout,
|
||||
.trace-layout,
|
||||
.settings-layout,
|
||||
.stats-grid,
|
||||
.card-grid-list,
|
||||
@ -1171,7 +1246,7 @@ dd {
|
||||
.item-grid,
|
||||
.action-grid,
|
||||
.split-view,
|
||||
.hero-meta {
|
||||
.compact-row-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -1183,13 +1258,18 @@ dd {
|
||||
.panel-fill {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.trace-row-top,
|
||||
.trace-row-summary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sidebar-brand,
|
||||
.panel,
|
||||
.subpanel,
|
||||
.workspace-header {
|
||||
.subpanel {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@ -1202,10 +1282,6 @@ dd {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.subpanel {
|
||||
padding: 1rem;
|
||||
|
||||
@ -4,6 +4,7 @@ export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
|
||||
|
||||
export type DesktopPrefsView = {
|
||||
launchAtLogin: boolean
|
||||
autoStartProxy: boolean
|
||||
minimizeToTray: boolean
|
||||
notifications: boolean
|
||||
theme: ThemePreference
|
||||
@ -15,6 +16,19 @@ export type DesktopPrefsSaveResult = {
|
||||
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 = {
|
||||
configPath: string
|
||||
content: string
|
||||
@ -36,6 +50,48 @@ export type ProxyStatusView = {
|
||||
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 = {
|
||||
configPath: string
|
||||
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 ProxySettings():Promise<app.ProxySettingsView>;
|
||||
|
||||
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 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 SaveProxySettings(arg1:app.ProxySettingsInput):Promise<app.ProxySettingsSaveResult>;
|
||||
|
||||
export function Service():Promise<app.Service>;
|
||||
|
||||
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;
|
||||
|
||||
@ -66,10 +66,18 @@ export function Providers() {
|
||||
return window['go']['desktop']['App']['Providers']();
|
||||
}
|
||||
|
||||
export function ProxySettings() {
|
||||
return window['go']['desktop']['App']['ProxySettings']();
|
||||
}
|
||||
|
||||
export function ProxyStatus() {
|
||||
return window['go']['desktop']['App']['ProxyStatus']();
|
||||
}
|
||||
|
||||
export function RequestTraces(arg1) {
|
||||
return window['go']['desktop']['App']['RequestTraces'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAlias(arg1) {
|
||||
return window['go']['desktop']['App']['SaveAlias'](arg1);
|
||||
}
|
||||
@ -86,6 +94,10 @@ export function SaveProvider(arg1) {
|
||||
return window['go']['desktop']['App']['SaveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function SaveProxySettings(arg1) {
|
||||
return window['go']['desktop']['App']['SaveProxySettings'](arg1);
|
||||
}
|
||||
|
||||
export function Service() {
|
||||
return window['go']['desktop']['App']['Service']();
|
||||
}
|
||||
|
||||
@ -132,6 +132,7 @@ export namespace app {
|
||||
}
|
||||
export class DesktopPrefsInput {
|
||||
launchAtLogin: boolean;
|
||||
autoStartProxy: boolean;
|
||||
minimizeToTray: boolean;
|
||||
notifications: boolean;
|
||||
theme: string;
|
||||
@ -144,6 +145,7 @@ export namespace app {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.launchAtLogin = source["launchAtLogin"];
|
||||
this.autoStartProxy = source["autoStartProxy"];
|
||||
this.minimizeToTray = source["minimizeToTray"];
|
||||
this.notifications = source["notifications"];
|
||||
this.theme = source["theme"];
|
||||
@ -152,6 +154,7 @@ export namespace app {
|
||||
}
|
||||
export class DesktopPrefsView {
|
||||
launchAtLogin: boolean;
|
||||
autoStartProxy: boolean;
|
||||
minimizeToTray: boolean;
|
||||
notifications: boolean;
|
||||
theme: string;
|
||||
@ -164,6 +167,7 @@ export namespace app {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.launchAtLogin = source["launchAtLogin"];
|
||||
this.autoStartProxy = source["autoStartProxy"];
|
||||
this.minimizeToTray = source["minimizeToTray"];
|
||||
this.notifications = source["notifications"];
|
||||
this.theme = source["theme"];
|
||||
@ -294,8 +298,7 @@ export namespace app {
|
||||
export class ProxyStatusView {
|
||||
running: boolean;
|
||||
bindAddress: string;
|
||||
// Go type: time
|
||||
startedAt?: any;
|
||||
startedAt?: string;
|
||||
lastError?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@ -306,27 +309,9 @@ export namespace app {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.running = source["running"];
|
||||
this.bindAddress = source["bindAddress"];
|
||||
this.startedAt = this.convertValues(source["startedAt"], null);
|
||||
this.startedAt = source["startedAt"];
|
||||
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 {
|
||||
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 {
|
||||
|
||||
|
||||
|
||||
@ -15,16 +15,18 @@ import (
|
||||
|
||||
type Service struct {
|
||||
configPath string
|
||||
traces *proxy.TraceStore
|
||||
|
||||
mu sync.Mutex
|
||||
proxyCancel context.CancelFunc
|
||||
proxyDone chan struct{}
|
||||
proxyReady chan error
|
||||
proxyErr error
|
||||
proxyStatus ProxyStatusView
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -155,7 +157,6 @@ func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResu
|
||||
}
|
||||
|
||||
func (s *Service) StartProxy(ctx context.Context) error {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
@ -165,29 +166,36 @@ func (s *Service) StartProxy(ctx context.Context) error {
|
||||
}
|
||||
|
||||
bindAddress := proxyBindAddress(cfg)
|
||||
started := false
|
||||
|
||||
s.mu.Lock()
|
||||
if s.proxyCancel == nil {
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
ready := make(chan error, 1)
|
||||
s.proxyCancel = cancel
|
||||
s.proxyDone = done
|
||||
s.proxyReady = ready
|
||||
s.proxyErr = nil
|
||||
s.proxyStatus = ProxyStatusView{
|
||||
Running: true,
|
||||
BindAddress: bindAddress,
|
||||
StartedAt: time.Now(),
|
||||
StartedAt: formatTimestamp(time.Now()),
|
||||
}
|
||||
started = true
|
||||
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
|
||||
go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
|
||||
}
|
||||
ready := s.proxyReady
|
||||
done := s.proxyDone
|
||||
s.mu.Unlock()
|
||||
|
||||
if !started {
|
||||
return nil
|
||||
select {
|
||||
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 {
|
||||
@ -234,6 +242,47 @@ func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
|
||||
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) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
@ -251,6 +300,7 @@ func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (D
|
||||
}
|
||||
cfg.Desktop = config.Desktop{
|
||||
LaunchAtLogin: in.LaunchAtLogin,
|
||||
AutoStartProxy: in.AutoStartProxy,
|
||||
MinimizeToTray: in.MinimizeToTray,
|
||||
Notifications: in.Notifications,
|
||||
Theme: normalizeThemePreference(in.Theme),
|
||||
@ -322,13 +372,14 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) {
|
||||
err := proxy.New(cfg).ListenAndServe(runCtx)
|
||||
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, s.traces).ListenAndServeWithReady(runCtx, ready)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.proxyDone == done {
|
||||
s.proxyCancel = nil
|
||||
s.proxyDone = nil
|
||||
s.proxyReady = nil
|
||||
}
|
||||
s.proxyErr = err
|
||||
status := s.proxyStatus
|
||||
@ -387,6 +438,7 @@ func doctorIssues(errs []error) []DoctorIssue {
|
||||
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
||||
return DesktopPrefsView{
|
||||
LaunchAtLogin: prefs.LaunchAtLogin,
|
||||
AutoStartProxy: prefs.AutoStartProxy,
|
||||
MinimizeToTray: prefs.MinimizeToTray,
|
||||
Notifications: prefs.Notifications,
|
||||
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 {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch trimmed {
|
||||
@ -460,6 +529,13 @@ func cloneHeaders(in map[string]string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func formatTimestamp(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func maskKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
|
||||
@ -23,6 +23,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
||||
|
||||
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
||||
LaunchAtLogin: true,
|
||||
AutoStartProxy: true,
|
||||
MinimizeToTray: true,
|
||||
Notifications: true,
|
||||
Theme: "dark",
|
||||
@ -31,7 +32,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -39,7 +40,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package app
|
||||
|
||||
import "time"
|
||||
import "github.com/Apale7/opencode-provider-switch/internal/proxy"
|
||||
|
||||
type Overview struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
@ -14,10 +14,123 @@ type Overview struct {
|
||||
type ProxyStatusView struct {
|
||||
Running bool `json:"running"`
|
||||
BindAddress string `json:"bindAddress"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
StartedAt string `json:"startedAt,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 {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
@ -96,6 +209,7 @@ type SyncResult struct {
|
||||
|
||||
type DesktopPrefsView struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
AutoStartProxy bool `json:"autoStartProxy"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
Theme string `json:"theme"`
|
||||
@ -109,6 +223,7 @@ type DesktopPrefsSaveResult struct {
|
||||
|
||||
type DesktopPrefsInput struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
AutoStartProxy bool `json:"autoStartProxy"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
Theme string `json:"theme"`
|
||||
|
||||
@ -54,11 +54,25 @@ type Server struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
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.
|
||||
type Desktop struct {
|
||||
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
||||
AutoStartProxy bool `json:"auto_start_proxy,omitempty"`
|
||||
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
||||
Notifications bool `json:"notifications,omitempty"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
@ -107,6 +121,11 @@ func Default() *Config {
|
||||
Host: "127.0.0.1",
|
||||
Port: 9982,
|
||||
APIKey: DefaultLocalAPIKey,
|
||||
ConnectTimeoutMs: DefaultConnectTimeoutMs,
|
||||
ResponseHeaderTimeoutMs: DefaultResponseHeaderTimeoutMs,
|
||||
FirstByteTimeoutMs: DefaultFirstByteTimeoutMs,
|
||||
RequestReadTimeoutMs: DefaultRequestReadTimeoutMs,
|
||||
StreamIdleTimeoutMs: DefaultStreamIdleTimeoutMs,
|
||||
},
|
||||
Desktop: Desktop{},
|
||||
Providers: []Provider{},
|
||||
@ -158,6 +177,7 @@ func Load(path string) (*Config, error) {
|
||||
if c.Server.APIKey == "" {
|
||||
c.Server.APIKey = DefaultLocalAPIKey
|
||||
}
|
||||
normalizeServerTimeouts(&c.Server)
|
||||
c.path = path
|
||||
return c, nil
|
||||
}
|
||||
@ -448,8 +468,41 @@ func (c *Config) Validate() []error {
|
||||
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))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || strings.EqualFold(host, "localhost") {
|
||||
|
||||
@ -2,6 +2,7 @@ package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@ -62,9 +63,27 @@ func (a *App) Startup(ctx context.Context) {
|
||||
a.notify.Attach(ctx)
|
||||
a.auto.Attach(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)
|
||||
}
|
||||
|
||||
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 {
|
||||
a.ctx = ctx
|
||||
prevent, _ := a.tray.BeforeClose(ctx)
|
||||
@ -194,6 +213,18 @@ func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
|
||||
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) {
|
||||
status, err := a.bindings.StartProxy(a.callContext())
|
||||
if err != nil {
|
||||
|
||||
@ -89,6 +89,18 @@ func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, err
|
||||
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) {
|
||||
if err := b.service.StartProxy(ctx); err != nil {
|
||||
return app.ProxyStatusView{}, err
|
||||
@ -167,6 +179,18 @@ func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
|
||||
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) {
|
||||
return b.StartProxy(context.Background())
|
||||
}
|
||||
|
||||
@ -298,6 +298,32 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
"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) {
|
||||
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")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
@ -89,11 +172,148 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||
if err != nil {
|
||||
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.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) {
|
||||
originalAuto := instance.auto
|
||||
instance.auto = failingAutoStart{message: "startup folder unavailable"}
|
||||
@ -407,6 +627,7 @@ func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
|
||||
type spyNotifier struct {
|
||||
syncCalls int
|
||||
lastPrefs app.DesktopPrefsView
|
||||
sends []notifyCall
|
||||
}
|
||||
|
||||
func (s *spyNotifier) Attach(_ context.Context) {}
|
||||
@ -418,6 +639,34 @@ func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
var firstByteTimeout = 15 * time.Second
|
||||
var requestReadTimeout = 30 * time.Second
|
||||
var streamIdleTimeout = 60 * time.Second
|
||||
|
||||
type openAIErrorEnvelope struct {
|
||||
Error openAIError `json:"error"`
|
||||
}
|
||||
@ -43,21 +39,31 @@ type Server struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
logger *log.Logger
|
||||
traces *TraceStore
|
||||
}
|
||||
|
||||
// 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{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
TLSHandshakeTimeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: firstByteTimeout,
|
||||
ResponseHeaderTimeout: minDuration(responseHeaderTimeout, firstByteTimeout),
|
||||
DisableCompression: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
@ -68,11 +74,18 @@ func New(cfg *config.Config) *Server {
|
||||
Timeout: 0,
|
||||
},
|
||||
logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
|
||||
traces: traces,
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP listener until ctx is cancelled.
|
||||
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)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/responses", s.handleResponses)
|
||||
@ -81,14 +94,21 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if ready != nil {
|
||||
ready <- err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: requestReadTimeout,
|
||||
ReadTimeout: timeoutDuration(s.cfg.Server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
go func() { errCh <- srv.Serve(listener) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@ -142,6 +162,7 @@ var reqCounter uint64
|
||||
// handleResponses is the main alias→failover proxy entry.
|
||||
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := atomic.AddUint64(&reqCounter, 1)
|
||||
startedAt := time.Now()
|
||||
if r.Method != http.MethodPost {
|
||||
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
@ -168,21 +189,50 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rawModel := 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"])
|
||||
alias := s.cfg.FindAlias(aliasName)
|
||||
if alias == nil {
|
||||
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))
|
||||
return
|
||||
}
|
||||
if !alias.Enabled {
|
||||
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))
|
||||
return
|
||||
}
|
||||
targets := s.cfg.AvailableTargets(*alias)
|
||||
if len(targets) == 0 {
|
||||
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))
|
||||
return
|
||||
}
|
||||
@ -190,28 +240,62 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
failoverCount := 0
|
||||
var lastRetryable *upstreamFailure
|
||||
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)
|
||||
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)
|
||||
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++
|
||||
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)
|
||||
cloned := cloneMap(payload)
|
||||
cloned["model"] = t.Model
|
||||
upstreamURL := strings.TrimRight(p.BaseURL, "/") + "/responses"
|
||||
attemptTrace.URL = upstreamURL
|
||||
attemptTrace.RequestParams = sanitizeJSONValue("", cloned)
|
||||
newBody, err := json.Marshal(cloned)
|
||||
if err != nil {
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount)
|
||||
if ok {
|
||||
handled, success, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount, &attemptTrace, &trace)
|
||||
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
|
||||
}
|
||||
if !retryable {
|
||||
s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr)
|
||||
trace.Error = errorString(upstreamErr)
|
||||
return
|
||||
}
|
||||
if failure != nil {
|
||||
@ -222,6 +306,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if lastRetryable != nil {
|
||||
trace.StatusCode = lastRetryable.status
|
||||
trace.Error = fmt.Sprintf("upstream %d", lastRetryable.status)
|
||||
copyResponseHeaders(w.Header(), lastRetryable.header)
|
||||
w.WriteHeader(lastRetryable.status)
|
||||
if len(lastRetryable.body) > 0 {
|
||||
@ -230,11 +316,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
}
|
||||
|
||||
// tryOnce proxies one attempt. Returns (ok, retryable, err, failure).
|
||||
// ok=true means successful response fully/partially written to client.
|
||||
// tryOnce proxies one attempt. Returns (handled, success, retryable, err, failure).
|
||||
// handled=true means a downstream response has already been started or completed.
|
||||
// retryable=true means failure happened before any bytes flushed downstream.
|
||||
func (s *Server) tryOnce(
|
||||
ctx context.Context,
|
||||
@ -246,14 +334,16 @@ func (s *Server) tryOnce(
|
||||
aliasName string,
|
||||
attempt 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)
|
||||
defer cancel()
|
||||
|
||||
upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses"
|
||||
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
|
||||
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)
|
||||
upReq.Header.Set("Content-Type", "application/json")
|
||||
@ -265,46 +355,95 @@ func (s *Server) tryOnce(
|
||||
upReq.Header.Set(k, v)
|
||||
}
|
||||
upReq.ContentLength = int64(len(body))
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.RequestHeaders = sanitizeHeaderMap(upReq.Header)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
firstByteTimeout := timeoutDuration(s.cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||
resp, err := s.client.Do(upReq)
|
||||
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()
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.StatusCode = resp.StatusCode
|
||||
attemptTrace.ResponseHeaders = sanitizeHeaderMap(resp.Header)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
|
||||
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 {
|
||||
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)
|
||||
copyResponseHeaders(w.Header(), resp.Header)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
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)
|
||||
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)
|
||||
if firstErr != nil {
|
||||
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 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 {
|
||||
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
|
||||
streamIdleTimeout := timeoutDuration(s.cfg.Server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
|
||||
if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil {
|
||||
isEventStream = mediaType == "text/event-stream"
|
||||
}
|
||||
@ -316,7 +455,11 @@ func (s *Server) tryOnce(
|
||||
flusher, _ := w.(http.Flusher)
|
||||
if len(firstChunk) > 0 {
|
||||
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 {
|
||||
flusher.Flush()
|
||||
@ -335,7 +478,11 @@ func (s *Server) tryOnce(
|
||||
}
|
||||
if n > 0 {
|
||||
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 {
|
||||
flusher.Flush()
|
||||
@ -343,14 +490,29 @@ func (s *Server) tryOnce(
|
||||
}
|
||||
if rerr != nil {
|
||||
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)
|
||||
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 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 {
|
||||
prefix := config.AppName + "/"
|
||||
if strings.HasPrefix(model, prefix) {
|
||||
|
||||
@ -105,6 +105,16 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||
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) {
|
||||
@ -156,10 +166,6 @@ func TestHandleResponsesFailsOverOnEmptySSE200(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) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@ -176,7 +182,7 @@ func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
|
||||
defer upstream.Close()
|
||||
|
||||
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"}},
|
||||
Aliases: []config.Alias{{
|
||||
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) {
|
||||
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