fix(desktop): stabilize proxy and trace flows

This commit is contained in:
apale7 2026-04-20 00:45:21 +08:00
parent bbe21e14df
commit a1eb4e244c
21 changed files with 2910 additions and 631 deletions

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,10 @@ import type {
ProviderStateInput, ProviderStateInput,
ProviderUpsertInput, ProviderUpsertInput,
ProviderView, ProviderView,
ProxySettingsSaveResult,
ProxySettingsView,
ProxyStatusView, ProxyStatusView,
RequestTrace,
SyncInput, SyncInput,
SyncPreview, SyncPreview,
SyncResult, SyncResult,
@ -157,6 +160,20 @@ export function getProxyStatus(): Promise<ProxyStatusView> {
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status') return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
} }
export function getProxySettings(): Promise<ProxySettingsView> {
return isWails() ? bridge().ProxySettings() : http<ProxySettingsView>('/api/proxy/settings')
}
export function saveProxySettings(input: ProxySettingsView): Promise<ProxySettingsSaveResult> {
return isWails()
? bridge().SaveProxySettings(input)
: http<ProxySettingsSaveResult>('/api/proxy/settings', { method: 'POST', body: JSON.stringify(input) })
}
export function listRequestTraces(): Promise<RequestTrace[]> {
return isWails() ? bridge().RequestTraces(100) : http<RequestTrace[]>('/api/proxy/traces')
}
export function startProxy(): Promise<ProxyStatusView> { export function startProxy(): Promise<ProxyStatusView> {
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' }) return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
} }

View File

@ -22,7 +22,10 @@ declare global {
UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView> UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
DoctorRun: () => Promise<import('./types').DoctorRunResult> DoctorRun: () => Promise<import('./types').DoctorRunResult>
ProxyStatus: () => Promise<import('./types').ProxyStatusView> ProxyStatus: () => Promise<import('./types').ProxyStatusView>
ProxySettings: () => Promise<import('./types').ProxySettingsView>
RequestTraces: (limit: number) => Promise<import('./types').RequestTrace[]>
StartProxy: () => Promise<import('./types').ProxyStatusView> StartProxy: () => Promise<import('./types').ProxyStatusView>
SaveProxySettings: (input: import('./types').ProxySettingsView) => Promise<import('./types').ProxySettingsSaveResult>
StopProxy: () => Promise<import('./types').ProxyStatusView> StopProxy: () => Promise<import('./types').ProxyStatusView>
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView> DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult> SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>

View File

@ -11,6 +11,8 @@
"overview": { "title": "Overview", "description": "Status, actions, and environment" }, "overview": { "title": "Overview", "description": "Status, actions, and environment" },
"providers": { "title": "Providers", "description": "Manage upstream endpoints" }, "providers": { "title": "Providers", "description": "Manage upstream endpoints" },
"aliases": { "title": "Aliases", "description": "Route models across targets" }, "aliases": { "title": "Aliases", "description": "Route models across targets" },
"log": { "title": "Log", "description": "Business routing, alias decisions, and failover chain" },
"network": { "title": "Network", "description": "Request metadata except LLM input and output content" },
"sync": { "title": "Sync", "description": "OpenCode sync and doctor" }, "sync": { "title": "Sync", "description": "OpenCode sync and doctor" },
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" } "settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
}, },
@ -32,7 +34,6 @@
"unbind": "Unbind", "unbind": "Unbind",
"newProvider": "New provider", "newProvider": "New provider",
"newAlias": "New alias", "newAlias": "New alias",
"useInBindForm": "Use in bind form",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
@ -112,6 +113,9 @@
"filterEnabled": "Enabled", "filterEnabled": "Enabled",
"filterDisabled": "Disabled", "filterDisabled": "Disabled",
"listCount": "{{shown}} shown / {{total}} total", "listCount": "{{shown}} shown / {{total}} total",
"detailHint": "Select one row to inspect and edit it on the right.",
"selectTitle": "Select a provider",
"selectHint": "The left list is optimized for density. Pick one row to inspect or edit details here.",
"empty": "No providers configured yet.", "empty": "No providers configured yet.",
"emptyHint": "Create one manually or import providers from your OpenCode config.", "emptyHint": "Create one manually or import providers from your OpenCode config.",
"noMatches": "No providers match the current filters.", "noMatches": "No providers match the current filters.",
@ -159,6 +163,9 @@
"search": "Search aliases", "search": "Search aliases",
"searchPlaceholder": "Filter by alias, display name, provider, or model", "searchPlaceholder": "Filter by alias, display name, provider, or model",
"listCount": "{{shown}} shown / {{total}} total", "listCount": "{{shown}} shown / {{total}} total",
"detailHint": "Select one row to inspect alias settings and targets on the right.",
"selectTitle": "Select an alias",
"selectHint": "The left list stays compact so you can scan more routes per screen. Pick one row to inspect or edit details here.",
"empty": "No aliases configured yet.", "empty": "No aliases configured yet.",
"emptyHint": "Create an alias first, then bind provider/model targets to it.", "emptyHint": "Create an alias first, then bind provider/model targets to it.",
"noMatches": "No aliases match the current filters.", "noMatches": "No aliases match the current filters.",
@ -209,12 +216,51 @@
"debugTitle": "Raw doctor JSON", "debugTitle": "Raw doctor JSON",
"debugSummary": "Show doctor output" "debugSummary": "Show doctor output"
}, },
"log": {
"title": "Request log",
"subtitle": "Inspect alias routing, final target, and failover chain for each request.",
"count": "{{count}} requests",
"empty": "No proxied requests yet.",
"emptyHint": "Start the proxy and send a request to populate the timeline.",
"success": "Success",
"failed": "Failed",
"failover": "Failover",
"detailTitle": "Business detail",
"detailSubtitle": "This panel focuses on alias-level routing semantics.",
"alias": "Alias",
"finalRoute": "Final route",
"status": "Status",
"totalTime": "Total time",
"startedAt": "Started at",
"firstByte": "First byte",
"stream": "Stream",
"chainTitle": "Attempt chain",
"attemptLabel": "Attempt {{attempt}}",
"provider": "Provider",
"model": "Model"
},
"network": {
"title": "Network detail",
"subtitle": "Inspect URL, timing, status, headers, and params without exposing model IO content.",
"detailTitle": "Network inspector",
"detailSubtitle": "Each attempt shows technical metadata captured around the upstream call.",
"url": "URL",
"firstByte": "TTFB",
"totalTime": "Total time",
"statusCode": "Status code",
"requestHeaders": "Client request headers",
"requestParams": "Client request params",
"responseHeaders": "Upstream response headers",
"responseBody": "Upstream response body",
"attemptTitle": "Attempt {{attempt}} · {{provider}} / {{model}}"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"subtitle": "Persist desktop behavior, theme preference, and language preference.", "subtitle": "Persist desktop behavior, theme preference, and language preference.",
"appearanceTitle": "Appearance", "appearanceTitle": "Appearance",
"languageTitle": "Language", "languageTitle": "Language",
"behaviorTitle": "Desktop behavior", "behaviorTitle": "Desktop behavior",
"timeoutTitle": "Proxy timeouts",
"configTitle": "Config file", "configTitle": "Config file",
"importTitle": "Import mode", "importTitle": "Import mode",
"importModeText": "Import text", "importModeText": "Import text",
@ -241,6 +287,15 @@
"resolvedTheme": "Resolved theme", "resolvedTheme": "Resolved theme",
"resolvedLanguage": "Resolved language", "resolvedLanguage": "Resolved language",
"launchAtLogin": "Launch at login", "launchAtLogin": "Launch at login",
"autoStartProxy": "Start proxy on app launch",
"connectTimeout": "Connect timeout (ms)",
"responseHeaderTimeout": "Response header timeout (ms)",
"firstByteTimeout": "First byte timeout (ms)",
"requestReadTimeout": "Client request read timeout (ms)",
"streamIdleTimeout": "Stream idle timeout (ms)",
"timeoutHint": "Saved values apply to the next proxy start.",
"timeoutRunningHint": "The proxy is currently running. Saved values apply only after you restart it.",
"saveTimeouts": "Save proxy timeouts",
"minimizeToTray": "Minimize to tray / close to background", "minimizeToTray": "Minimize to tray / close to background",
"notifications": "Native notifications" "notifications": "Native notifications"
}, },

View File

@ -11,6 +11,8 @@
"overview": { "title": "概览", "description": "状态、操作与环境信息" }, "overview": { "title": "概览", "description": "状态、操作与环境信息" },
"providers": { "title": "提供商", "description": "管理上游端点" }, "providers": { "title": "提供商", "description": "管理上游端点" },
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" }, "aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
"log": { "title": "日志", "description": "查看业务路由、alias 决策与 failover 链路" },
"network": { "title": "网络", "description": "查看除 LLM 输入输出内容外的全部请求元数据" },
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" }, "sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" } "settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
}, },
@ -32,7 +34,6 @@
"unbind": "解绑", "unbind": "解绑",
"newProvider": "新建提供商", "newProvider": "新建提供商",
"newAlias": "新建别名", "newAlias": "新建别名",
"useInBindForm": "带入绑定表单",
"close": "关闭", "close": "关闭",
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
@ -112,6 +113,9 @@
"filterEnabled": "已启用", "filterEnabled": "已启用",
"filterDisabled": "已禁用", "filterDisabled": "已禁用",
"listCount": "显示 {{shown}} / 共 {{total}}", "listCount": "显示 {{shown}} / 共 {{total}}",
"detailHint": "左侧选中一行后,在右侧查看并编辑详情。",
"selectTitle": "选择一个提供商",
"selectHint": "左侧列表已压缩为高密度模式,适合首屏浏览更多条目;选中一行后在这里查看或编辑详情。",
"empty": "还没有配置任何提供商。", "empty": "还没有配置任何提供商。",
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。", "emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
"noMatches": "当前筛选条件下没有匹配的提供商。", "noMatches": "当前筛选条件下没有匹配的提供商。",
@ -159,6 +163,9 @@
"search": "搜索别名", "search": "搜索别名",
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤", "searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
"listCount": "显示 {{shown}} / 共 {{total}}", "listCount": "显示 {{shown}} / 共 {{total}}",
"detailHint": "左侧选中一行后,在右侧查看别名设置和目标详情。",
"selectTitle": "选择一个别名",
"selectHint": "左侧列表保持紧凑,方便一屏浏览更多路由;选中一行后在这里查看或编辑详情。",
"empty": "还没有配置任何别名。", "empty": "还没有配置任何别名。",
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。", "emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
"noMatches": "当前筛选条件下没有匹配的别名。", "noMatches": "当前筛选条件下没有匹配的别名。",
@ -209,12 +216,51 @@
"debugTitle": "原始 Doctor JSON", "debugTitle": "原始 Doctor JSON",
"debugSummary": "查看 Doctor 输出" "debugSummary": "查看 Doctor 输出"
}, },
"log": {
"title": "请求日志",
"subtitle": "按请求查看 alias 路由、最终目标与 failover 链路。",
"count": "共 {{count}} 条请求",
"empty": "还没有代理请求。",
"emptyHint": "先启动代理并发起一次请求,时间线才会出现内容。",
"success": "成功",
"failed": "失败",
"failover": "发生切换",
"detailTitle": "业务详情",
"detailSubtitle": "这里聚焦 alias 级别的业务路由语义。",
"alias": "Alias",
"finalRoute": "最终路由",
"status": "状态码",
"totalTime": "总耗时",
"startedAt": "开始时间",
"firstByte": "首字节时间",
"stream": "流式",
"chainTitle": "尝试链路",
"attemptLabel": "第 {{attempt}} 次尝试",
"provider": "提供商",
"model": "模型"
},
"network": {
"title": "网络详情",
"subtitle": "查看 URL、时延、状态码、请求头与参数同时避免暴露模型输入输出正文。",
"detailTitle": "网络检查器",
"detailSubtitle": "每次尝试都会展示调用上游时采集到的技术元数据。",
"url": "URL",
"firstByte": "TTFB",
"totalTime": "总耗时",
"statusCode": "状态码",
"requestHeaders": "客户端请求头",
"requestParams": "客户端请求参数",
"responseHeaders": "上游响应头",
"responseBody": "上游响应体",
"attemptTitle": "第 {{attempt}} 次尝试 · {{provider}} / {{model}}"
},
"settings": { "settings": {
"title": "设置", "title": "设置",
"subtitle": "持久化桌面行为、主题偏好和语言偏好。", "subtitle": "持久化桌面行为、主题偏好和语言偏好。",
"appearanceTitle": "外观", "appearanceTitle": "外观",
"languageTitle": "语言", "languageTitle": "语言",
"behaviorTitle": "桌面行为", "behaviorTitle": "桌面行为",
"timeoutTitle": "代理超时",
"configTitle": "配置文件", "configTitle": "配置文件",
"importTitle": "导入方式", "importTitle": "导入方式",
"importModeText": "文本导入", "importModeText": "文本导入",
@ -241,6 +287,15 @@
"resolvedTheme": "实际主题", "resolvedTheme": "实际主题",
"resolvedLanguage": "实际语言", "resolvedLanguage": "实际语言",
"launchAtLogin": "开机启动", "launchAtLogin": "开机启动",
"autoStartProxy": "启动应用时自动开启代理",
"connectTimeout": "连接超时(毫秒)",
"responseHeaderTimeout": "响应头超时(毫秒)",
"firstByteTimeout": "首字节超时(毫秒)",
"requestReadTimeout": "客户端请求读取超时(毫秒)",
"streamIdleTimeout": "流空闲超时(毫秒)",
"timeoutHint": "保存后会在下一次启动代理时生效。",
"timeoutRunningHint": "代理当前正在运行,保存后的值要在重启代理后才会生效。",
"saveTimeouts": "保存代理超时",
"minimizeToTray": "最小化到托盘 / 关闭时转入后台", "minimizeToTray": "最小化到托盘 / 关闭时转入后台",
"notifications": "系统通知" "notifications": "系统通知"
}, },

View File

@ -263,8 +263,7 @@ html[data-theme='dark'] .sidebar {
.sidebar-brand, .sidebar-brand,
.panel, .panel,
.subpanel, .subpanel {
.workspace-header {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 24px; border-radius: 24px;
background: var(--panel); background: var(--panel);
@ -304,7 +303,6 @@ html[data-theme='dark'] .sidebar {
} }
.eyebrow, .eyebrow,
.section-kicker,
.meta-label, .meta-label,
.stat-label, .stat-label,
dt { dt {
@ -316,7 +314,6 @@ dt {
} }
.sidebar-brand h1, .sidebar-brand h1,
.workspace-header h2,
.panel h3, .panel h3,
.subpanel h4 { .subpanel h4 {
margin: 0; margin: 0;
@ -326,10 +323,6 @@ dt {
font-size: 1.1rem; font-size: 1.1rem;
} }
.workspace-header h2 {
font-size: clamp(1.85rem, 2vw, 2.45rem);
}
.subtle { .subtle {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
@ -388,7 +381,6 @@ html[data-theme='dark'] .nav-item.active {
border-color: transparent; border-color: transparent;
} }
html[data-theme='dark'] .nav-item.active .nav-description,
html[data-theme='dark'] .nav-item.active .nav-title { html[data-theme='dark'] .nav-item.active .nav-title {
color: #08111f; color: #08111f;
} }
@ -399,13 +391,8 @@ html[data-theme='dark'] .nav-item.active .nav-title {
width: 100%; width: 100%;
} }
.nav-description {
display: none;
}
.panel-header, .panel-header,
.toolbar, .toolbar,
.hero-actions,
.item-heading, .item-heading,
.subpanel-header, .subpanel-header,
.list-toolbar { .list-toolbar {
@ -423,8 +410,7 @@ html[data-theme='dark'] .nav-item.active .nav-title {
.stack, .stack,
.stack-blocks, .stack-blocks,
.issue-stack, .issue-stack,
.target-list, .target-list {
.hero-meta {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
} }
@ -433,7 +419,6 @@ html[data-theme='dark'] .nav-item.active .nav-title {
.target-card, .target-card,
.item-card, .item-card,
.stat-card, .stat-card,
.hero-meta-card,
.inline-meta { .inline-meta {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 18px; border-radius: 18px;
@ -448,75 +433,6 @@ html[data-theme='dark'] .nav-item.active .nav-title {
padding: 1.5rem; padding: 1.5rem;
} }
.workspace-header {
display: grid;
gap: 1.25rem;
padding: 1.25rem;
background:
linear-gradient(135deg, var(--panel-strong), var(--panel-soft)),
radial-gradient(circle at top right, var(--accent-soft), transparent 32%);
}
.hero-copy {
display: grid;
gap: 0.5rem;
}
.hero-copy .subtle {
max-width: 52rem;
}
.hero-actions-wrap {
display: flex;
flex-wrap: wrap;
align-items: stretch;
justify-content: space-between;
gap: 1.1rem;
}
.hero-meta {
grid-template-columns: repeat(2, minmax(0, 1fr));
flex: 1;
min-width: min(100%, 460px);
}
.hero-meta-card {
display: grid;
gap: 0.35rem;
padding: 1rem 1.05rem;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.hero-meta-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
.hero-meta-card strong {
font-size: 1rem;
word-break: break-word;
}
.hero-status-card {
position: relative;
overflow: hidden;
}
.hero-status-card::after {
content: '';
position: absolute;
inset: auto -22px -40px auto;
width: 92px;
height: 92px;
border-radius: 999px;
background: radial-gradient(circle, var(--accent-soft), transparent 70%);
}
.tab-layout { .tab-layout {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@ -528,10 +444,15 @@ html[data-theme='dark'] .nav-item.active .nav-title {
} }
.sync-layout, .sync-layout,
.trace-layout,
.settings-layout { .settings-layout {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.trace-layout {
grid-template-columns: minmax(300px, 0.78fr) minmax(0, 1.22fr);
}
.settings-layout { .settings-layout {
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr); grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr);
align-items: start; align-items: start;
@ -552,6 +473,85 @@ html[data-theme='dark'] .nav-item.active .nav-title {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.trace-list-panel {
min-width: 0;
}
.trace-list {
display: grid;
gap: 0.85rem;
max-height: calc(100vh - 18rem);
overflow: auto;
padding-right: 0.2rem;
}
.trace-row {
display: grid;
gap: 0.7rem;
width: 100%;
padding: 1rem;
border-radius: 18px;
text-align: left;
background: var(--panel-muted);
}
.trace-row.active {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
.trace-row-top,
.trace-row-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.trace-row-summary {
color: var(--text-muted);
font-size: 0.9rem;
}
.trace-hero {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.trace-hero > div,
.chain-card,
.trace-detail-block {
padding: 0.95rem;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel-muted);
}
.trace-hero > div {
display: grid;
gap: 0.35rem;
}
.chain-list {
display: grid;
gap: 0.8rem;
}
.chain-card {
display: grid;
gap: 0.8rem;
}
.trace-pill-warn {
background: var(--warning-soft);
color: var(--warning);
}
.compact-header {
margin-bottom: 0;
}
.stats-grid, .stats-grid,
.action-grid, .action-grid,
.info-grid, .info-grid,
@ -681,6 +681,11 @@ html[data-theme='dark'] button.ghost-danger:hover {
align-items: start; align-items: start;
} }
.providers-layout,
.aliases-layout {
grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.08fr);
}
.editor-column, .editor-column,
.list-column { .list-column {
min-width: 0; min-width: 0;
@ -717,6 +722,78 @@ html[data-theme='dark'] button.ghost-danger:hover {
overflow: auto; overflow: auto;
} }
.compact-list {
gap: 0.7rem;
}
.compact-row {
display: grid;
gap: 0.75rem;
width: 100%;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel-muted);
text-align: left;
box-shadow: none;
}
.compact-row:hover,
.compact-row.active {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.compact-row-main,
.compact-row-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.compact-row-titleblock {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.compact-row-titleblock code,
.compact-row-summary span {
color: var(--text-muted);
}
.compact-row-titleblock strong,
.compact-row-titleblock code,
.compact-row-summary span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.compact-row-summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(110px, auto));
align-items: center;
font-size: 0.88rem;
}
.detail-panel {
display: grid;
align-content: start;
}
.compact-inline-meta {
align-items: flex-start;
}
.compact-inline-meta > div {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.scroll-list::-webkit-scrollbar { .scroll-list::-webkit-scrollbar {
width: 10px; width: 10px;
} }
@ -1108,15 +1185,14 @@ dd {
} }
.nav-list { .nav-list {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
}
.nav-description {
display: none;
} }
.overview-layout, .overview-layout,
.sync-layout, .sync-layout,
.trace-layout,
.providers-layout,
.aliases-layout,
.stats-grid, .stats-grid,
.card-grid-list, .card-grid-list,
.info-grid, .info-grid,
@ -1139,9 +1215,7 @@ dd {
padding: 1rem; padding: 1rem;
} }
.workspace-header,
.panel-header, .panel-header,
.hero-actions,
.toolbar, .toolbar,
.card-actions, .card-actions,
.subpanel-header, .subpanel-header,
@ -1150,7 +1224,7 @@ dd {
.target-card-main, .target-card-main,
.item-heading, .item-heading,
.item-summary, .item-summary,
.hero-actions-wrap, .compact-row-main,
.inline-meta { .inline-meta {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@ -1164,6 +1238,7 @@ dd {
.nav-list, .nav-list,
.overview-layout, .overview-layout,
.sync-layout, .sync-layout,
.trace-layout,
.settings-layout, .settings-layout,
.stats-grid, .stats-grid,
.card-grid-list, .card-grid-list,
@ -1171,7 +1246,7 @@ dd {
.item-grid, .item-grid,
.action-grid, .action-grid,
.split-view, .split-view,
.hero-meta { .compact-row-summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -1183,13 +1258,18 @@ dd {
.panel-fill { .panel-fill {
min-height: auto; min-height: auto;
} }
.trace-row-top,
.trace-row-summary {
flex-direction: column;
align-items: stretch;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.sidebar-brand, .sidebar-brand,
.panel, .panel,
.subpanel, .subpanel {
.workspace-header {
border-radius: 20px; border-radius: 20px;
} }
@ -1202,10 +1282,6 @@ dd {
align-items: flex-start; align-items: flex-start;
} }
.workspace-header {
padding: 1rem;
}
.panel, .panel,
.subpanel { .subpanel {
padding: 1rem; padding: 1rem;

View File

@ -4,6 +4,7 @@ export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
export type DesktopPrefsView = { export type DesktopPrefsView = {
launchAtLogin: boolean launchAtLogin: boolean
autoStartProxy: boolean
minimizeToTray: boolean minimizeToTray: boolean
notifications: boolean notifications: boolean
theme: ThemePreference theme: ThemePreference
@ -15,6 +16,19 @@ export type DesktopPrefsSaveResult = {
warnings?: string[] warnings?: string[]
} }
export type ProxySettingsView = {
connectTimeoutMs: number
responseHeaderTimeoutMs: number
firstByteTimeoutMs: number
requestReadTimeoutMs: number
streamIdleTimeoutMs: number
}
export type ProxySettingsSaveResult = {
settings: ProxySettingsView
warnings?: string[]
}
export type ConfigExportView = { export type ConfigExportView = {
configPath: string configPath: string
content: string content: string
@ -36,6 +50,48 @@ export type ProxyStatusView = {
lastError?: string lastError?: string
} }
export type TraceAttempt = {
attempt: number
provider?: string
model?: string
url?: string
startedAt: string
durationMs: number
firstByteMs?: number
statusCode?: number
success: boolean
retryable: boolean
skipped: boolean
result?: string
error?: string
requestHeaders?: Record<string, string>
requestParams?: unknown
responseHeaders?: Record<string, string>
responseBody?: string
}
export type RequestTrace = {
id: number
startedAt: string
finishedAt?: string
durationMs: number
firstByteMs?: number
rawModel?: string
alias?: string
stream: boolean
success: boolean
statusCode?: number
error?: string
finalProvider?: string
finalModel?: string
finalUrl?: string
failover: boolean
attemptCount: number
requestHeaders?: Record<string, string>
requestParams?: unknown
attempts: TraceAttempt[]
}
export type Overview = { export type Overview = {
configPath: string configPath: string
providerCount: number providerCount: number

View File

@ -36,8 +36,12 @@ export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
export function Providers():Promise<Array<app.ProviderView>>; export function Providers():Promise<Array<app.ProviderView>>;
export function ProxySettings():Promise<app.ProxySettingsView>;
export function ProxyStatus():Promise<app.ProxyStatusView>; export function ProxyStatus():Promise<app.ProxyStatusView>;
export function RequestTraces(arg1:number):Promise<Array<app.RequestTrace>>;
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>; export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>; export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
@ -46,6 +50,8 @@ export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsSa
export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>; export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>;
export function SaveProxySettings(arg1:app.ProxySettingsInput):Promise<app.ProxySettingsSaveResult>;
export function Service():Promise<app.Service>; export function Service():Promise<app.Service>;
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>; export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;

View File

@ -66,10 +66,18 @@ export function Providers() {
return window['go']['desktop']['App']['Providers'](); return window['go']['desktop']['App']['Providers']();
} }
export function ProxySettings() {
return window['go']['desktop']['App']['ProxySettings']();
}
export function ProxyStatus() { export function ProxyStatus() {
return window['go']['desktop']['App']['ProxyStatus'](); return window['go']['desktop']['App']['ProxyStatus']();
} }
export function RequestTraces(arg1) {
return window['go']['desktop']['App']['RequestTraces'](arg1);
}
export function SaveAlias(arg1) { export function SaveAlias(arg1) {
return window['go']['desktop']['App']['SaveAlias'](arg1); return window['go']['desktop']['App']['SaveAlias'](arg1);
} }
@ -86,6 +94,10 @@ export function SaveProvider(arg1) {
return window['go']['desktop']['App']['SaveProvider'](arg1); return window['go']['desktop']['App']['SaveProvider'](arg1);
} }
export function SaveProxySettings(arg1) {
return window['go']['desktop']['App']['SaveProxySettings'](arg1);
}
export function Service() { export function Service() {
return window['go']['desktop']['App']['Service'](); return window['go']['desktop']['App']['Service']();
} }

View File

@ -132,6 +132,7 @@ export namespace app {
} }
export class DesktopPrefsInput { export class DesktopPrefsInput {
launchAtLogin: boolean; launchAtLogin: boolean;
autoStartProxy: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
notifications: boolean; notifications: boolean;
theme: string; theme: string;
@ -144,6 +145,7 @@ export namespace app {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.launchAtLogin = source["launchAtLogin"]; this.launchAtLogin = source["launchAtLogin"];
this.autoStartProxy = source["autoStartProxy"];
this.minimizeToTray = source["minimizeToTray"]; this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"]; this.notifications = source["notifications"];
this.theme = source["theme"]; this.theme = source["theme"];
@ -152,6 +154,7 @@ export namespace app {
} }
export class DesktopPrefsView { export class DesktopPrefsView {
launchAtLogin: boolean; launchAtLogin: boolean;
autoStartProxy: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
notifications: boolean; notifications: boolean;
theme: string; theme: string;
@ -164,6 +167,7 @@ export namespace app {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.launchAtLogin = source["launchAtLogin"]; this.launchAtLogin = source["launchAtLogin"];
this.autoStartProxy = source["autoStartProxy"];
this.minimizeToTray = source["minimizeToTray"]; this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"]; this.notifications = source["notifications"];
this.theme = source["theme"]; this.theme = source["theme"];
@ -294,8 +298,7 @@ export namespace app {
export class ProxyStatusView { export class ProxyStatusView {
running: boolean; running: boolean;
bindAddress: string; bindAddress: string;
// Go type: time startedAt?: string;
startedAt?: any;
lastError?: string; lastError?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@ -306,27 +309,9 @@ export namespace app {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"]; this.running = source["running"];
this.bindAddress = source["bindAddress"]; this.bindAddress = source["bindAddress"];
this.startedAt = this.convertValues(source["startedAt"], null); this.startedAt = source["startedAt"];
this.lastError = source["lastError"]; this.lastError = source["lastError"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
} }
export class Overview { export class Overview {
configPath: string; configPath: string;
@ -501,7 +486,190 @@ export namespace app {
} }
} }
export class ProxySettingsInput {
connectTimeoutMs: number;
responseHeaderTimeoutMs: number;
firstByteTimeoutMs: number;
requestReadTimeoutMs: number;
streamIdleTimeoutMs: number;
static createFrom(source: any = {}) {
return new ProxySettingsInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connectTimeoutMs = source["connectTimeoutMs"];
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
}
}
export class ProxySettingsView {
connectTimeoutMs: number;
responseHeaderTimeoutMs: number;
firstByteTimeoutMs: number;
requestReadTimeoutMs: number;
streamIdleTimeoutMs: number;
static createFrom(source: any = {}) {
return new ProxySettingsView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connectTimeoutMs = source["connectTimeoutMs"];
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
}
}
export class ProxySettingsSaveResult {
settings: ProxySettingsView;
warnings?: string[];
static createFrom(source: any = {}) {
return new ProxySettingsSaveResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.settings = this.convertValues(source["settings"], ProxySettingsView);
this.warnings = source["warnings"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class TraceAttempt {
attempt: number;
provider?: string;
model?: string;
url?: string;
startedAt: string;
durationMs: number;
firstByteMs?: number;
statusCode?: number;
success: boolean;
retryable: boolean;
skipped: boolean;
result?: string;
error?: string;
requestHeaders?: Record<string, string>;
requestParams?: any;
responseHeaders?: Record<string, string>;
responseBody?: string;
static createFrom(source: any = {}) {
return new TraceAttempt(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.attempt = source["attempt"];
this.provider = source["provider"];
this.model = source["model"];
this.url = source["url"];
this.startedAt = source["startedAt"];
this.durationMs = source["durationMs"];
this.firstByteMs = source["firstByteMs"];
this.statusCode = source["statusCode"];
this.success = source["success"];
this.retryable = source["retryable"];
this.skipped = source["skipped"];
this.result = source["result"];
this.error = source["error"];
this.requestHeaders = source["requestHeaders"];
this.requestParams = source["requestParams"];
this.responseHeaders = source["responseHeaders"];
this.responseBody = source["responseBody"];
}
}
export class RequestTrace {
id: number;
startedAt: string;
finishedAt?: string;
durationMs: number;
firstByteMs?: number;
rawModel?: string;
alias?: string;
stream: boolean;
success: boolean;
statusCode?: number;
error?: string;
finalProvider?: string;
finalModel?: string;
finalUrl?: string;
failover: boolean;
attemptCount: number;
requestHeaders?: Record<string, string>;
requestParams?: any;
attempts: TraceAttempt[];
static createFrom(source: any = {}) {
return new RequestTrace(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.startedAt = source["startedAt"];
this.finishedAt = source["finishedAt"];
this.durationMs = source["durationMs"];
this.firstByteMs = source["firstByteMs"];
this.rawModel = source["rawModel"];
this.alias = source["alias"];
this.stream = source["stream"];
this.success = source["success"];
this.statusCode = source["statusCode"];
this.error = source["error"];
this.finalProvider = source["finalProvider"];
this.finalModel = source["finalModel"];
this.finalUrl = source["finalUrl"];
this.failover = source["failover"];
this.attemptCount = source["attemptCount"];
this.requestHeaders = source["requestHeaders"];
this.requestParams = source["requestParams"];
this.attempts = this.convertValues(source["attempts"], TraceAttempt);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Service { export class Service {

View File

@ -15,16 +15,18 @@ import (
type Service struct { type Service struct {
configPath string configPath string
traces *proxy.TraceStore
mu sync.Mutex mu sync.Mutex
proxyCancel context.CancelFunc proxyCancel context.CancelFunc
proxyDone chan struct{} proxyDone chan struct{}
proxyReady chan error
proxyErr error proxyErr error
proxyStatus ProxyStatusView proxyStatus ProxyStatusView
} }
func NewService(configPath string) *Service { func NewService(configPath string) *Service {
return &Service{configPath: strings.TrimSpace(configPath)} return &Service{configPath: strings.TrimSpace(configPath), traces: proxy.NewTraceStore(200)}
} }
func (s *Service) ConfigPath() string { func (s *Service) ConfigPath() string {
@ -155,7 +157,6 @@ func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResu
} }
func (s *Service) StartProxy(ctx context.Context) error { func (s *Service) StartProxy(ctx context.Context) error {
_ = ctx
cfg, err := s.loadConfig() cfg, err := s.loadConfig()
if err != nil { if err != nil {
return err return err
@ -165,29 +166,36 @@ func (s *Service) StartProxy(ctx context.Context) error {
} }
bindAddress := proxyBindAddress(cfg) bindAddress := proxyBindAddress(cfg)
started := false
s.mu.Lock() s.mu.Lock()
if s.proxyCancel == nil { if s.proxyCancel == nil {
runCtx, cancel := context.WithCancel(context.Background()) runCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}) done := make(chan struct{})
ready := make(chan error, 1)
s.proxyCancel = cancel s.proxyCancel = cancel
s.proxyDone = done s.proxyDone = done
s.proxyReady = ready
s.proxyErr = nil s.proxyErr = nil
s.proxyStatus = ProxyStatusView{ s.proxyStatus = ProxyStatusView{
Running: true, Running: true,
BindAddress: bindAddress, BindAddress: bindAddress,
StartedAt: time.Now(), StartedAt: formatTimestamp(time.Now()),
} }
started = true go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
} }
ready := s.proxyReady
done := s.proxyDone
s.mu.Unlock() s.mu.Unlock()
if !started { select {
return nil case err := <-ready:
return err
case <-done:
return s.WaitProxy(context.Background())
case <-ctx.Done():
_ = s.StopProxy(context.Background())
return ctx.Err()
} }
return nil
} }
func (s *Service) StopProxy(ctx context.Context) error { func (s *Service) StopProxy(ctx context.Context) error {
@ -234,6 +242,47 @@ func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
return s.currentProxyStatus(proxyBindAddress(cfg)), nil return s.currentProxyStatus(proxyBindAddress(cfg)), nil
} }
func (s *Service) GetProxySettings(ctx context.Context) (ProxySettingsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxySettingsView{}, err
}
return proxySettingsView(cfg.Server), nil
}
func (s *Service) SaveProxySettings(ctx context.Context, in ProxySettingsInput) (ProxySettingsSaveResult, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxySettingsSaveResult{}, err
}
cfg.Server.ConnectTimeoutMs = normalizePositiveInt(in.ConnectTimeoutMs, config.DefaultConnectTimeoutMs)
cfg.Server.ResponseHeaderTimeoutMs = normalizePositiveInt(in.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
cfg.Server.FirstByteTimeoutMs = normalizePositiveInt(in.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
cfg.Server.RequestReadTimeoutMs = normalizePositiveInt(in.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs)
cfg.Server.StreamIdleTimeoutMs = normalizePositiveInt(in.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
if err := cfg.Save(); err != nil {
return ProxySettingsSaveResult{}, err
}
warnings := []string{}
status := s.currentProxyStatus(proxyBindAddress(cfg))
if status.Running {
warnings = append(warnings, "saved proxy timeout settings; restart proxy to apply changes")
}
return ProxySettingsSaveResult{Settings: proxySettingsView(cfg.Server), Warnings: warnings}, nil
}
func (s *Service) ListRequestTraces(ctx context.Context, limit int) ([]RequestTrace, error) {
_ = ctx
raw := s.traces.List(limit)
out := make([]RequestTrace, 0, len(raw))
for _, trace := range raw {
out = append(out, requestTraceView(trace))
}
return out, nil
}
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) { func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
_ = ctx _ = ctx
cfg, err := s.loadConfig() cfg, err := s.loadConfig()
@ -251,6 +300,7 @@ func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (D
} }
cfg.Desktop = config.Desktop{ cfg.Desktop = config.Desktop{
LaunchAtLogin: in.LaunchAtLogin, LaunchAtLogin: in.LaunchAtLogin,
AutoStartProxy: in.AutoStartProxy,
MinimizeToTray: in.MinimizeToTray, MinimizeToTray: in.MinimizeToTray,
Notifications: in.Notifications, Notifications: in.Notifications,
Theme: normalizeThemePreference(in.Theme), Theme: normalizeThemePreference(in.Theme),
@ -322,13 +372,14 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
return status return status
} }
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) { func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, ready chan error, cfg *config.Config, bindAddress string) {
err := proxy.New(cfg).ListenAndServe(runCtx) err := proxy.New(cfg, s.traces).ListenAndServeWithReady(runCtx, ready)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.proxyDone == done { if s.proxyDone == done {
s.proxyCancel = nil s.proxyCancel = nil
s.proxyDone = nil s.proxyDone = nil
s.proxyReady = nil
} }
s.proxyErr = err s.proxyErr = err
status := s.proxyStatus status := s.proxyStatus
@ -387,6 +438,7 @@ func doctorIssues(errs []error) []DoctorIssue {
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView { func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
return DesktopPrefsView{ return DesktopPrefsView{
LaunchAtLogin: prefs.LaunchAtLogin, LaunchAtLogin: prefs.LaunchAtLogin,
AutoStartProxy: prefs.AutoStartProxy,
MinimizeToTray: prefs.MinimizeToTray, MinimizeToTray: prefs.MinimizeToTray,
Notifications: prefs.Notifications, Notifications: prefs.Notifications,
Theme: normalizeThemePreference(prefs.Theme), Theme: normalizeThemePreference(prefs.Theme),
@ -394,6 +446,23 @@ func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
} }
} }
func proxySettingsView(server config.Server) ProxySettingsView {
return ProxySettingsView{
ConnectTimeoutMs: normalizePositiveInt(server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
ResponseHeaderTimeoutMs: normalizePositiveInt(server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs),
FirstByteTimeoutMs: normalizePositiveInt(server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs),
RequestReadTimeoutMs: normalizePositiveInt(server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
StreamIdleTimeoutMs: normalizePositiveInt(server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs),
}
}
func normalizePositiveInt(value int, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
func normalizeThemePreference(value string) string { func normalizeThemePreference(value string) string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
switch trimmed { switch trimmed {
@ -460,6 +529,13 @@ func cloneHeaders(in map[string]string) map[string]string {
return out return out
} }
func formatTimestamp(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339Nano)
}
func maskKey(k string) string { func maskKey(k string) string {
if k == "" { if k == "" {
return "" return ""

View File

@ -23,6 +23,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{ prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
LaunchAtLogin: true, LaunchAtLogin: true,
AutoStartProxy: true,
MinimizeToTray: true, MinimizeToTray: true,
Notifications: true, Notifications: true,
Theme: "dark", Theme: "dark",
@ -31,7 +32,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveDesktopPrefs() error = %v", err) t.Fatalf("SaveDesktopPrefs() error = %v", err)
} }
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" { if !prefs.LaunchAtLogin || !prefs.AutoStartProxy || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
t.Fatalf("SaveDesktopPrefs() = %#v", prefs) t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
} }
@ -39,7 +40,7 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("config.Load() error = %v", err) t.Fatalf("config.Load() error = %v", err)
} }
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" { if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.AutoStartProxy || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop) t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
} }
} }
@ -62,6 +63,102 @@ func TestSaveDesktopPrefsNormalizesUnknownThemeAndLanguage(t *testing.T) {
} }
} }
func TestSaveProxySettingsPersistsToConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
ConnectTimeoutMs: 12000,
ResponseHeaderTimeoutMs: 21000,
FirstByteTimeoutMs: 22000,
RequestReadTimeoutMs: 33000,
StreamIdleTimeoutMs: 70000,
})
if err != nil {
t.Fatalf("SaveProxySettings() error = %v", err)
}
if result.Settings.ConnectTimeoutMs != 12000 || result.Settings.ResponseHeaderTimeoutMs != 21000 || result.Settings.FirstByteTimeoutMs != 22000 || result.Settings.RequestReadTimeoutMs != 33000 || result.Settings.StreamIdleTimeoutMs != 70000 {
t.Fatalf("SaveProxySettings() = %#v", result.Settings)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if cfg.Server.ConnectTimeoutMs != 12000 || cfg.Server.ResponseHeaderTimeoutMs != 21000 || cfg.Server.FirstByteTimeoutMs != 22000 || cfg.Server.RequestReadTimeoutMs != 33000 || cfg.Server.StreamIdleTimeoutMs != 70000 {
t.Fatalf("persisted server settings = %#v", cfg.Server)
}
}
func TestSaveProxySettingsWarnsWhenProxyRunning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
port := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
if err := svc.StartProxy(context.Background()); err != nil {
t.Fatalf("StartProxy() error = %v", err)
}
defer func() { _ = svc.StopProxy(context.Background()) }()
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{ConnectTimeoutMs: 12000})
if err != nil {
t.Fatalf("SaveProxySettings() error = %v", err)
}
if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "restart proxy") {
t.Fatalf("warnings = %#v", result.Warnings)
}
}
func TestSaveProxySettingsNormalizesNonPositiveValues(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
ConnectTimeoutMs: 0,
ResponseHeaderTimeoutMs: -1,
FirstByteTimeoutMs: 0,
RequestReadTimeoutMs: -50,
StreamIdleTimeoutMs: 0,
})
if err != nil {
t.Fatalf("SaveProxySettings() error = %v", err)
}
if result.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
result.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
result.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
result.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
result.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
t.Fatalf("normalized settings = %#v", result.Settings)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if cfg.Server.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
cfg.Server.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
cfg.Server.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
cfg.Server.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
cfg.Server.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
t.Fatalf("persisted server settings = %#v", cfg.Server)
}
}
func TestStartStopProxyUpdatesStatus(t *testing.T) { func TestStartStopProxyUpdatesStatus(t *testing.T) {
t.Parallel() t.Parallel()
@ -116,6 +213,104 @@ func TestStartStopProxyUpdatesStatus(t *testing.T) {
} }
} }
func TestStartProxyReturnsBindErrorWithoutRunningStatus(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
port := freePort(t)
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
err = svc.StartProxy(context.Background())
if err == nil {
t.Fatal("StartProxy() error = nil, want bind failure")
}
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
t.Fatalf("StartProxy() error = %v, want bind failure", err)
}
status, statusErr := svc.GetProxyStatus(context.Background())
if statusErr != nil {
t.Fatalf("GetProxyStatus() error = %v", statusErr)
}
if status.Running {
t.Fatalf("status = %#v, want stopped", status)
}
if status.LastError == "" {
t.Fatalf("status = %#v, want last error", status)
}
}
func TestConcurrentStartProxyCallsShareStartupResult(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
port := freePort(t)
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
errCh := make(chan error, 2)
start := make(chan struct{})
for range 2 {
go func() {
<-start
errCh <- svc.StartProxy(context.Background())
}()
}
close(start)
for range 2 {
err := <-errCh
if err == nil {
t.Fatal("StartProxy() error = nil, want bind failure")
}
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
t.Fatalf("StartProxy() error = %v, want bind failure", err)
}
}
status, statusErr := svc.GetProxyStatus(context.Background())
if statusErr != nil {
t.Fatalf("GetProxyStatus() error = %v", statusErr)
}
if status.Running {
t.Fatalf("status = %#v, want stopped", status)
}
if status.LastError == "" {
t.Fatalf("status = %#v, want last error", status)
}
}
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) { func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -1,6 +1,6 @@
package app package app
import "time" import "github.com/Apale7/opencode-provider-switch/internal/proxy"
type Overview struct { type Overview struct {
ConfigPath string `json:"configPath"` ConfigPath string `json:"configPath"`
@ -12,10 +12,123 @@ type Overview struct {
} }
type ProxyStatusView struct { type ProxyStatusView struct {
Running bool `json:"running"` Running bool `json:"running"`
BindAddress string `json:"bindAddress"` BindAddress string `json:"bindAddress"`
StartedAt time.Time `json:"startedAt,omitempty"` StartedAt string `json:"startedAt,omitempty"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
}
type ProxySettingsView struct {
ConnectTimeoutMs int `json:"connectTimeoutMs"`
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
}
type ProxySettingsInput struct {
ConnectTimeoutMs int `json:"connectTimeoutMs"`
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
}
type ProxySettingsSaveResult struct {
Settings ProxySettingsView `json:"settings"`
Warnings []string `json:"warnings,omitempty"`
}
type RequestTrace struct {
ID uint64 `json:"id"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
FirstByteMs int64 `json:"firstByteMs,omitempty"`
RawModel string `json:"rawModel,omitempty"`
Alias string `json:"alias,omitempty"`
Stream bool `json:"stream"`
Success bool `json:"success"`
StatusCode int `json:"statusCode,omitempty"`
Error string `json:"error,omitempty"`
FinalProvider string `json:"finalProvider,omitempty"`
FinalModel string `json:"finalModel,omitempty"`
FinalURL string `json:"finalUrl,omitempty"`
Failover bool `json:"failover"`
AttemptCount int `json:"attemptCount"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestParams any `json:"requestParams,omitempty"`
Attempts []TraceAttempt `json:"attempts"`
}
type TraceAttempt struct {
Attempt int `json:"attempt"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
URL string `json:"url,omitempty"`
StartedAt string `json:"startedAt"`
DurationMs int64 `json:"durationMs"`
FirstByteMs int64 `json:"firstByteMs,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Success bool `json:"success"`
Retryable bool `json:"retryable"`
Skipped bool `json:"skipped"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestParams any `json:"requestParams,omitempty"`
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
ResponseBody string `json:"responseBody,omitempty"`
}
func requestTraceView(trace proxy.RequestTrace) RequestTrace {
attempts := make([]TraceAttempt, 0, len(trace.Attempts))
for _, attempt := range trace.Attempts {
attempts = append(attempts, traceAttemptView(attempt))
}
return RequestTrace{
ID: trace.ID,
StartedAt: formatTimestamp(trace.StartedAt),
FinishedAt: formatTimestamp(trace.FinishedAt),
DurationMs: trace.DurationMs,
FirstByteMs: trace.FirstByteMs,
RawModel: trace.RawModel,
Alias: trace.Alias,
Stream: trace.Stream,
Success: trace.Success,
StatusCode: trace.StatusCode,
Error: trace.Error,
FinalProvider: trace.FinalProvider,
FinalModel: trace.FinalModel,
FinalURL: trace.FinalURL,
Failover: trace.Failover,
AttemptCount: trace.AttemptCount,
RequestHeaders: trace.RequestHeaders,
RequestParams: trace.RequestParams,
Attempts: attempts,
}
}
func traceAttemptView(attempt proxy.TraceAttempt) TraceAttempt {
return TraceAttempt{
Attempt: attempt.Attempt,
Provider: attempt.Provider,
Model: attempt.Model,
URL: attempt.URL,
StartedAt: formatTimestamp(attempt.StartedAt),
DurationMs: attempt.DurationMs,
FirstByteMs: attempt.FirstByteMs,
StatusCode: attempt.StatusCode,
Success: attempt.Success,
Retryable: attempt.Retryable,
Skipped: attempt.Skipped,
Result: attempt.Result,
Error: attempt.Error,
RequestHeaders: attempt.RequestHeaders,
RequestParams: attempt.RequestParams,
ResponseHeaders: attempt.ResponseHeaders,
ResponseBody: attempt.ResponseBody,
}
} }
type ProviderView struct { type ProviderView struct {
@ -96,6 +209,7 @@ type SyncResult struct {
type DesktopPrefsView struct { type DesktopPrefsView struct {
LaunchAtLogin bool `json:"launchAtLogin"` LaunchAtLogin bool `json:"launchAtLogin"`
AutoStartProxy bool `json:"autoStartProxy"`
MinimizeToTray bool `json:"minimizeToTray"` MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"` Notifications bool `json:"notifications"`
Theme string `json:"theme"` Theme string `json:"theme"`
@ -109,6 +223,7 @@ type DesktopPrefsSaveResult struct {
type DesktopPrefsInput struct { type DesktopPrefsInput struct {
LaunchAtLogin bool `json:"launchAtLogin"` LaunchAtLogin bool `json:"launchAtLogin"`
AutoStartProxy bool `json:"autoStartProxy"`
MinimizeToTray bool `json:"minimizeToTray"` MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"` Notifications bool `json:"notifications"`
Theme string `json:"theme"` Theme string `json:"theme"`

View File

@ -51,14 +51,28 @@ type Provider struct {
// Server holds proxy listen settings. // Server holds proxy listen settings.
type Server struct { type Server struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
ConnectTimeoutMs int `json:"connect_timeout_ms,omitempty"`
ResponseHeaderTimeoutMs int `json:"response_header_timeout_ms,omitempty"`
FirstByteTimeoutMs int `json:"first_byte_timeout_ms,omitempty"`
RequestReadTimeoutMs int `json:"request_read_timeout_ms,omitempty"`
StreamIdleTimeoutMs int `json:"stream_idle_timeout_ms,omitempty"`
} }
const (
DefaultConnectTimeoutMs = 10_000
DefaultResponseHeaderTimeoutMs = 15_000
DefaultFirstByteTimeoutMs = 15_000
DefaultRequestReadTimeoutMs = 30_000
DefaultStreamIdleTimeoutMs = 60_000
)
// Desktop holds desktop-shell user preferences. // Desktop holds desktop-shell user preferences.
type Desktop struct { type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"` LaunchAtLogin bool `json:"launch_at_login,omitempty"`
AutoStartProxy bool `json:"auto_start_proxy,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"` MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"` Notifications bool `json:"notifications,omitempty"`
Theme string `json:"theme,omitempty"` Theme string `json:"theme,omitempty"`
@ -104,9 +118,14 @@ func ValidateProviderBaseURL(baseURL string) error {
func Default() *Config { func Default() *Config {
return &Config{ return &Config{
Server: Server{ Server: Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 9982, Port: 9982,
APIKey: DefaultLocalAPIKey, APIKey: DefaultLocalAPIKey,
ConnectTimeoutMs: DefaultConnectTimeoutMs,
ResponseHeaderTimeoutMs: DefaultResponseHeaderTimeoutMs,
FirstByteTimeoutMs: DefaultFirstByteTimeoutMs,
RequestReadTimeoutMs: DefaultRequestReadTimeoutMs,
StreamIdleTimeoutMs: DefaultStreamIdleTimeoutMs,
}, },
Desktop: Desktop{}, Desktop: Desktop{},
Providers: []Provider{}, Providers: []Provider{},
@ -158,6 +177,7 @@ func Load(path string) (*Config, error) {
if c.Server.APIKey == "" { if c.Server.APIKey == "" {
c.Server.APIKey = DefaultLocalAPIKey c.Server.APIKey = DefaultLocalAPIKey
} }
normalizeServerTimeouts(&c.Server)
c.path = path c.path = path
return c, nil return c, nil
} }
@ -448,8 +468,41 @@ func (c *Config) Validate() []error {
if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) { if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) {
errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host)) errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host))
} }
if c.Server.ConnectTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.connect_timeout_ms must be greater than 0"))
}
if c.Server.ResponseHeaderTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.response_header_timeout_ms must be greater than 0"))
}
if c.Server.FirstByteTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.first_byte_timeout_ms must be greater than 0"))
}
if c.Server.RequestReadTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.request_read_timeout_ms must be greater than 0"))
}
if c.Server.StreamIdleTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.stream_idle_timeout_ms must be greater than 0"))
}
return errs return errs
} }
func normalizeServerTimeouts(server *Server) {
if server == nil {
return
}
server.ConnectTimeoutMs = normalizeServerTimeoutMs(server.ConnectTimeoutMs, DefaultConnectTimeoutMs)
server.ResponseHeaderTimeoutMs = normalizeServerTimeoutMs(server.ResponseHeaderTimeoutMs, DefaultResponseHeaderTimeoutMs)
server.FirstByteTimeoutMs = normalizeServerTimeoutMs(server.FirstByteTimeoutMs, DefaultFirstByteTimeoutMs)
server.RequestReadTimeoutMs = normalizeServerTimeoutMs(server.RequestReadTimeoutMs, DefaultRequestReadTimeoutMs)
server.StreamIdleTimeoutMs = normalizeServerTimeoutMs(server.StreamIdleTimeoutMs, DefaultStreamIdleTimeoutMs)
}
func normalizeServerTimeoutMs(value int, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
func isLoopbackHost(host string) bool { func isLoopbackHost(host string) bool {
host = strings.TrimSpace(host) host = strings.TrimSpace(host)
if host == "" || strings.EqualFold(host, "localhost") { if host == "" || strings.EqualFold(host, "localhost") {

View File

@ -2,6 +2,7 @@ package desktop
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
@ -62,9 +63,27 @@ func (a *App) Startup(ctx context.Context) {
a.notify.Attach(ctx) a.notify.Attach(ctx)
a.auto.Attach(ctx) a.auto.Attach(ctx)
_ = a.SyncDesktopPreferences(ctx) _ = a.SyncDesktopPreferences(ctx)
if prefs, err := a.bindings.GetDesktopPrefs(ctx); err == nil && prefs.AutoStartProxy {
_, _ = a.bindings.StartProxy(ctx)
a.watchAutoStartProxyFailure()
}
a.tray.RefreshProxyStatus(ctx) a.tray.RefreshProxyStatus(ctx)
} }
func (a *App) watchAutoStartProxyFailure() {
go func() {
waitCtx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
err := a.service.WaitProxy(waitCtx)
if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return
}
callCtx := a.callContext()
a.tray.RefreshProxyStatus(callCtx)
_ = a.notify.Send(callCtx, "Proxy failed to start", err.Error())
}()
}
func (a *App) BeforeClose(ctx context.Context) bool { func (a *App) BeforeClose(ctx context.Context) bool {
a.ctx = ctx a.ctx = ctx
prevent, _ := a.tray.BeforeClose(ctx) prevent, _ := a.tray.BeforeClose(ctx)
@ -194,6 +213,18 @@ func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
return a.bindings.GetProxyStatus(a.callContext()) return a.bindings.GetProxyStatus(a.callContext())
} }
func (a *App) RequestTraces(limit int) ([]app.RequestTrace, error) {
return a.bindings.ListRequestTraces(a.callContext(), limit)
}
func (a *App) ProxySettings() (app.ProxySettingsView, error) {
return a.bindings.GetProxySettings(a.callContext())
}
func (a *App) SaveProxySettings(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
return a.bindings.SaveProxySettings(a.callContext(), in)
}
func (a *App) StartProxy() (app.ProxyStatusView, error) { func (a *App) StartProxy() (app.ProxyStatusView, error) {
status, err := a.bindings.StartProxy(a.callContext()) status, err := a.bindings.StartProxy(a.callContext())
if err != nil { if err != nil {

View File

@ -89,6 +89,18 @@ func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, err
return b.service.GetProxyStatus(ctx) return b.service.GetProxyStatus(ctx)
} }
func (b *Bindings) ListRequestTraces(ctx context.Context, limit int) ([]app.RequestTrace, error) {
return b.service.ListRequestTraces(ctx, limit)
}
func (b *Bindings) GetProxySettings(ctx context.Context) (app.ProxySettingsView, error) {
return b.service.GetProxySettings(ctx)
}
func (b *Bindings) SaveProxySettings(ctx context.Context, in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
return b.service.SaveProxySettings(ctx, in)
}
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) { func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
if err := b.service.StartProxy(ctx); err != nil { if err := b.service.StartProxy(ctx); err != nil {
return app.ProxyStatusView{}, err return app.ProxyStatusView{}, err
@ -167,6 +179,18 @@ func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
return b.GetProxyStatus(context.Background()) return b.GetProxyStatus(context.Background())
} }
func (b *Bindings) RequestTraces(limit int) ([]app.RequestTrace, error) {
return b.ListRequestTraces(context.Background(), limit)
}
func (b *Bindings) ProxySettings() (app.ProxySettingsView, error) {
return b.GetProxySettings(context.Background())
}
func (b *Bindings) SaveProxyConfig(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
return b.SaveProxySettings(context.Background(), in)
}
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) { func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
return b.StartProxy(context.Background()) return b.StartProxy(context.Background())
} }

View File

@ -298,6 +298,32 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
writeResult(w, data, err) writeResult(w, data, err)
}) })
api.HandleFunc("/api/proxy/settings", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := b.GetProxySettings(r.Context())
writeResult(w, data, err)
case http.MethodPost:
var in appcore.ProxySettingsInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.SaveProxySettings(r.Context(), in)
writeResult(w, data, err)
default:
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
}
})
api.HandleFunc("/api/proxy/traces", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.ListRequestTraces(r.Context(), 100)
writeResult(w, data, err)
})
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)

View File

@ -4,12 +4,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app" "github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
@ -76,8 +78,89 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
} }
}) })
t.Run("request traces api", func(t *testing.T) {
instance.Service().ListRequestTraces(context.Background(), 10)
req := httptest.NewRequest(http.MethodGet, "/api/proxy/traces", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
var payload struct {
Data []app.RequestTrace `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(payload.Data) != 0 {
t.Fatalf("traces = %#v, want empty list", payload.Data)
}
})
t.Run("proxy settings api", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/proxy/settings", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
var payload struct {
Data app.ProxySettingsView `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.ConnectTimeoutMs != config.DefaultConnectTimeoutMs || payload.Data.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs {
t.Fatalf("proxy settings = %#v", payload.Data)
}
})
t.Run("save proxy settings", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":12000,"responseHeaderTimeoutMs":21000,"firstByteTimeoutMs":22000,"requestReadTimeoutMs":33000,"streamIdleTimeoutMs":70000}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
loaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if loaded.Server.ConnectTimeoutMs != 12000 || loaded.Server.ResponseHeaderTimeoutMs != 21000 || loaded.Server.FirstByteTimeoutMs != 22000 || loaded.Server.RequestReadTimeoutMs != 33000 || loaded.Server.StreamIdleTimeoutMs != 70000 {
t.Fatalf("persisted server settings = %#v", loaded.Server)
}
})
t.Run("save proxy settings normalizes non-positive values", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":0,"responseHeaderTimeoutMs":-1,"firstByteTimeoutMs":0,"requestReadTimeoutMs":-50,"streamIdleTimeoutMs":0}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data app.ProxySettingsSaveResult `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
payload.Data.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
payload.Data.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
payload.Data.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
payload.Data.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
t.Fatalf("proxy settings payload = %#v", payload.Data.Settings)
}
})
t.Run("save desktop prefs", func(t *testing.T) { t.Run("save desktop prefs", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`)) req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"autoStartProxy":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
h.ServeHTTP(resp, req) h.ServeHTTP(resp, req)
@ -89,11 +172,148 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("config.Load() error = %v", err) t.Fatalf("config.Load() error = %v", err)
} }
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" { if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.AutoStartProxy || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" {
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop) t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
} }
}) })
t.Run("startup auto starts proxy when enabled", func(t *testing.T) {
port := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
cfg.Desktop.AutoStartProxy = true
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
tray := &spyTray{}
notify := &spyNotifier{}
originalTray := instance.tray
originalNotify := instance.notify
defer func() {
instance.tray = originalTray
instance.notify = originalNotify
_ = instance.Service().StopProxy(context.Background())
}()
instance.tray = tray
instance.notify = notify
instance.Startup(context.Background())
status, err := instance.Service().GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if !status.Running {
t.Fatalf("status = %#v, want running", status)
}
if tray.refreshCalls != 1 {
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
}
if len(notify.sends) != 0 {
t.Fatalf("startup notifications = %#v, want none", notify.sends)
}
})
t.Run("startup auto start surfaces failure without success toast", func(t *testing.T) {
port := freePort(t)
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
cfg.Desktop.AutoStartProxy = true
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
tray := &spyTray{}
notify := &spyNotifier{}
originalTray := instance.tray
originalNotify := instance.notify
defer func() {
instance.tray = originalTray
instance.notify = originalNotify
_ = instance.Service().StopProxy(context.Background())
}()
instance.tray = tray
instance.notify = notify
instance.Startup(context.Background())
assertEventually(t, func() bool {
return len(notify.sends) == 1
})
status, err := instance.Service().GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if status.Running {
t.Fatalf("status = %#v, want stopped after failure", status)
}
if status.LastError == "" {
t.Fatalf("status = %#v, want last error", status)
}
if len(notify.sends) != 1 {
t.Fatalf("notifications = %#v, want 1 failure notice", notify.sends)
}
if notify.sends[0].Title != "Proxy failed to start" {
t.Fatalf("notification = %#v, want failure title", notify.sends[0])
}
if !strings.Contains(strings.ToLower(notify.sends[0].Body), "bind") {
t.Fatalf("notification = %#v, want bind error", notify.sends[0])
}
if tray.refreshCalls < 2 {
t.Fatalf("tray refreshCalls = %d, want refresh after failure", tray.refreshCalls)
}
})
t.Run("proxy start api surfaces bind failure", func(t *testing.T) {
port := freePort(t)
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
cfg.Desktop.AutoStartProxy = false
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/proxy/start", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
if !strings.Contains(strings.ToLower(resp.Body.String()), "bind") {
t.Fatalf("body = %q, want bind error", resp.Body.String())
}
})
t.Run("save desktop prefs keeps success with integration warning", func(t *testing.T) { t.Run("save desktop prefs keeps success with integration warning", func(t *testing.T) {
originalAuto := instance.auto originalAuto := instance.auto
instance.auto = failingAutoStart{message: "startup folder unavailable"} instance.auto = failingAutoStart{message: "startup folder unavailable"}
@ -407,6 +627,7 @@ func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
type spyNotifier struct { type spyNotifier struct {
syncCalls int syncCalls int
lastPrefs app.DesktopPrefsView lastPrefs app.DesktopPrefsView
sends []notifyCall
} }
func (s *spyNotifier) Attach(_ context.Context) {} func (s *spyNotifier) Attach(_ context.Context) {}
@ -418,6 +639,34 @@ func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
s.lastPrefs = prefs s.lastPrefs = prefs
} }
func (s *spyNotifier) Send(_ context.Context, _ string, _ string) error { func (s *spyNotifier) Send(_ context.Context, title string, body string) error {
s.sends = append(s.sends, notifyCall{Title: title, Body: body})
return nil return nil
} }
type notifyCall struct {
Title string
Body string
}
func assertEventually(t *testing.T, fn func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not satisfied before timeout")
}
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}

View File

@ -18,10 +18,6 @@ import (
"github.com/Apale7/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
var firstByteTimeout = 15 * time.Second
var requestReadTimeout = 30 * time.Second
var streamIdleTimeout = 60 * time.Second
type openAIErrorEnvelope struct { type openAIErrorEnvelope struct {
Error openAIError `json:"error"` Error openAIError `json:"error"`
} }
@ -43,21 +39,31 @@ type Server struct {
cfg *config.Config cfg *config.Config
client *http.Client client *http.Client
logger *log.Logger logger *log.Logger
traces *TraceStore
} }
// New constructs a Server from cfg. // New constructs a Server from cfg.
func New(cfg *config.Config) *Server { func New(cfg *config.Config, stores ...*TraceStore) *Server {
var traces *TraceStore
if len(stores) > 0 {
traces = stores[0]
}
if traces == nil {
traces = NewTraceStore(defaultTraceLimit)
}
firstByteTimeout := timeoutDuration(cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
responseHeaderTimeout := timeoutDuration(cfg.Server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
transport := &http.Transport{ transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 10 * time.Second, Timeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
}).DialContext, }).DialContext,
MaxIdleConns: 100, MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: firstByteTimeout, ResponseHeaderTimeout: minDuration(responseHeaderTimeout, firstByteTimeout),
DisableCompression: false, DisableCompression: false,
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
} }
@ -68,11 +74,18 @@ func New(cfg *config.Config) *Server {
Timeout: 0, Timeout: 0,
}, },
logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds), logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
traces: traces,
} }
} }
// ListenAndServe starts the HTTP listener until ctx is cancelled. // ListenAndServe starts the HTTP listener until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error { func (s *Server) ListenAndServe(ctx context.Context) error {
return s.ListenAndServeWithReady(ctx, nil)
}
// ListenAndServeWithReady starts the HTTP listener until ctx is cancelled and
// reports whether the listening socket was bound successfully.
func (s *Server) ListenAndServeWithReady(ctx context.Context, ready chan<- error) error {
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port) addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/responses", s.handleResponses) mux.HandleFunc("/v1/responses", s.handleResponses)
@ -81,14 +94,21 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`)) _, _ = w.Write([]byte(`{"status":"ok"}`))
}) })
listener, err := net.Listen("tcp", addr)
if ready != nil {
ready <- err
}
if err != nil {
return err
}
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: requestReadTimeout, ReadTimeout: timeoutDuration(s.cfg.Server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }() go func() { errCh <- srv.Serve(listener) }()
select { select {
case <-ctx.Done(): case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -142,6 +162,7 @@ var reqCounter uint64
// handleResponses is the main alias→failover proxy entry. // handleResponses is the main alias→failover proxy entry.
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
reqID := atomic.AddUint64(&reqCounter, 1) reqID := atomic.AddUint64(&reqCounter, 1)
startedAt := time.Now()
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return return
@ -168,21 +189,50 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
} }
rawModel := aliasName rawModel := aliasName
aliasName = normalizeAliasName(aliasName) aliasName = normalizeAliasName(aliasName)
trace := RequestTrace{
ID: reqID,
StartedAt: startedAt,
RawModel: rawModel,
Alias: aliasName,
RequestHeaders: sanitizeHeaderMap(r.Header),
RequestParams: sanitizeJSONValue("", payload),
}
if stream, ok := payload["stream"].(bool); ok {
trace.Stream = stream
}
defer func() {
trace.FinishedAt = time.Now()
trace.DurationMs = trace.FinishedAt.Sub(trace.StartedAt).Milliseconds()
trace.AttemptCount = len(trace.Attempts)
trace.Failover = len(trace.Attempts) > 1
if trace.FirstByteMs == 0 {
for _, attempt := range trace.Attempts {
if attempt.FirstByteMs > 0 {
trace.FirstByteMs = attempt.FirstByteMs
break
}
}
}
s.traces.Add(trace)
}()
s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"]) s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"])
alias := s.cfg.FindAlias(aliasName) alias := s.cfg.FindAlias(aliasName)
if alias == nil { if alias == nil {
s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName) s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName)
trace.Error = fmt.Sprintf("alias %q not found", aliasName)
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName)) writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName))
return return
} }
if !alias.Enabled { if !alias.Enabled {
s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName) s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName)
trace.Error = fmt.Sprintf("alias %q is disabled", aliasName)
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName)) writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
return return
} }
targets := s.cfg.AvailableTargets(*alias) targets := s.cfg.AvailableTargets(*alias)
if len(targets) == 0 { if len(targets) == 0 {
s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName) s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName)
trace.Error = fmt.Sprintf("alias %q has no available targets", aliasName)
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName)) writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName))
return return
} }
@ -190,28 +240,62 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
failoverCount := 0 failoverCount := 0
var lastRetryable *upstreamFailure var lastRetryable *upstreamFailure
for attempt, t := range targets { for attempt, t := range targets {
attemptTrace := TraceAttempt{
Attempt: attempt + 1,
Provider: t.Provider,
Model: t.Model,
StartedAt: time.Now(),
Result: "pending",
}
p := s.cfg.FindProvider(t.Provider) p := s.cfg.FindProvider(t.Provider)
if p == nil || !p.IsEnabled() { if p == nil || !p.IsEnabled() {
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider) s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider)
attemptTrace.Skipped = true
attemptTrace.Result = "skipped"
attemptTrace.Error = fmt.Sprintf("provider %q unavailable", t.Provider)
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
trace.Attempts = append(trace.Attempts, attemptTrace)
failoverCount++ failoverCount++
continue continue
} }
s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount) s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount)
cloned := cloneMap(payload) cloned := cloneMap(payload)
cloned["model"] = t.Model cloned["model"] = t.Model
upstreamURL := strings.TrimRight(p.BaseURL, "/") + "/responses"
attemptTrace.URL = upstreamURL
attemptTrace.RequestParams = sanitizeJSONValue("", cloned)
newBody, err := json.Marshal(cloned) newBody, err := json.Marshal(cloned)
if err != nil { if err != nil {
s.logger.Printf("req=%d marshal error: %v", reqID, err) s.logger.Printf("req=%d marshal error: %v", reqID, err)
attemptTrace.Result = "internal_error"
attemptTrace.Error = "marshal error"
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
trace.Attempts = append(trace.Attempts, attemptTrace)
trace.Error = "marshal error"
writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error") writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error")
return return
} }
ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount) handled, success, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount, &attemptTrace, &trace)
if ok { attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
trace.Attempts = append(trace.Attempts, attemptTrace)
trace.FinalProvider = p.ID
trace.FinalModel = t.Model
trace.FinalURL = upstreamURL
trace.StatusCode = attemptTrace.StatusCode
if trace.FirstByteMs == 0 {
trace.FirstByteMs = attemptTrace.FirstByteMs
}
if handled {
trace.Success = success
if !success {
trace.Error = errorString(upstreamErr)
}
return return
} }
if !retryable { if !retryable {
s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr) s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr)
trace.Error = errorString(upstreamErr)
return return
} }
if failure != nil { if failure != nil {
@ -222,6 +306,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
} }
if lastRetryable != nil { if lastRetryable != nil {
trace.StatusCode = lastRetryable.status
trace.Error = fmt.Sprintf("upstream %d", lastRetryable.status)
copyResponseHeaders(w.Header(), lastRetryable.header) copyResponseHeaders(w.Header(), lastRetryable.header)
w.WriteHeader(lastRetryable.status) w.WriteHeader(lastRetryable.status)
if len(lastRetryable.body) > 0 { if len(lastRetryable.body) > 0 {
@ -230,11 +316,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
return return
} }
trace.StatusCode = http.StatusBadGateway
trace.Error = fmt.Sprintf("all upstream targets failed for alias %q", aliasName)
writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName)) writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName))
} }
// tryOnce proxies one attempt. Returns (ok, retryable, err, failure). // tryOnce proxies one attempt. Returns (handled, success, retryable, err, failure).
// ok=true means successful response fully/partially written to client. // handled=true means a downstream response has already been started or completed.
// retryable=true means failure happened before any bytes flushed downstream. // retryable=true means failure happened before any bytes flushed downstream.
func (s *Server) tryOnce( func (s *Server) tryOnce(
ctx context.Context, ctx context.Context,
@ -246,14 +334,16 @@ func (s *Server) tryOnce(
aliasName string, aliasName string,
attempt int, attempt int,
failoverCount int, failoverCount int,
) (ok bool, retryable bool, err error, failure *upstreamFailure) { attemptTrace *TraceAttempt,
trace *RequestTrace,
) (handled bool, success bool, retryable bool, err error, failure *upstreamFailure) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses" upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses"
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil { if err != nil {
return false, false, fmt.Errorf("build request: %w", err), nil return false, false, false, fmt.Errorf("build request: %w", err), nil
} }
copyForwardHeaders(upReq.Header, clientReq.Header) copyForwardHeaders(upReq.Header, clientReq.Header)
upReq.Header.Set("Content-Type", "application/json") upReq.Header.Set("Content-Type", "application/json")
@ -265,46 +355,95 @@ func (s *Server) tryOnce(
upReq.Header.Set(k, v) upReq.Header.Set(k, v)
} }
upReq.ContentLength = int64(len(body)) upReq.ContentLength = int64(len(body))
if attemptTrace != nil {
attemptTrace.RequestHeaders = sanitizeHeaderMap(upReq.Header)
}
startedAt := time.Now() startedAt := time.Now()
firstByteTimeout := timeoutDuration(s.cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
resp, err := s.client.Do(upReq) resp, err := s.client.Do(upReq)
if err != nil { if err != nil {
return false, true, fmt.Errorf("upstream dial/transport: %w", err), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "transport_error"
attemptTrace.Error = fmt.Sprintf("upstream dial/transport: %v", err)
}
return false, false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if attemptTrace != nil {
attemptTrace.StatusCode = resp.StatusCode
attemptTrace.ResponseHeaders = sanitizeHeaderMap(resp.Header)
}
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
failure = captureRetryableFailure(resp) failure = captureRetryableFailure(resp)
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure sanitizedBody := sanitizeResponseBody(resp.Header.Get("Content-Type"), failure.body)
if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "retryable_failure"
attemptTrace.Error = fmt.Sprintf("upstream %d: %s", resp.StatusCode, sanitizedBody)
attemptTrace.ResponseBody = sanitizedBody
}
return false, false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, sanitizedBody), failure
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode) s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
if attemptTrace != nil {
attemptTrace.Result = "final_failure"
}
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount) s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
copyResponseHeaders(w.Header(), resp.Header) copyResponseHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil if attemptTrace != nil {
attemptTrace.ResponseBody = sanitizeResponseBody(resp.Header.Get("Content-Type"), bodyBytes)
}
_, _ = w.Write(bodyBytes)
return true, false, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
} }
remaining := firstByteTimeout - time.Since(startedAt) remaining := firstByteTimeout - time.Since(startedAt)
if remaining <= 0 { if remaining <= 0 {
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
} }
firstChunk, firstErr := readFirstChunk(resp.Body, remaining) firstChunk, firstErr := readFirstChunk(resp.Body, remaining)
if firstErr != nil { if firstErr != nil {
if errors.Is(firstErr, errFirstByteTimeout) { if errors.Is(firstErr, errFirstByteTimeout) {
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "first_byte_timeout"
attemptTrace.Error = fmt.Sprintf("upstream first byte timeout after %s", firstByteTimeout)
}
return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
} }
if errors.Is(firstErr, io.EOF) { if errors.Is(firstErr, io.EOF) {
if len(firstChunk) == 0 { if len(firstChunk) == 0 {
return false, true, fmt.Errorf("upstream closed before first byte"), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "empty_response"
attemptTrace.Error = "upstream closed before first byte"
}
return false, false, true, fmt.Errorf("upstream closed before first byte"), nil
} }
} else { } else {
return false, true, fmt.Errorf("upstream first read: %w", firstErr), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "first_read_error"
attemptTrace.Error = fmt.Sprintf("upstream first read: %v", firstErr)
}
return false, false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
} }
} }
if attemptTrace != nil {
attemptTrace.FirstByteMs = time.Since(startedAt).Milliseconds()
}
if trace != nil && trace.FirstByteMs == 0 && attemptTrace != nil {
trace.FirstByteMs = attemptTrace.FirstByteMs
}
isEventStream := false isEventStream := false
streamIdleTimeout := timeoutDuration(s.cfg.Server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil { if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil {
isEventStream = mediaType == "text/event-stream" isEventStream = mediaType == "text/event-stream"
} }
@ -316,7 +455,11 @@ func (s *Server) tryOnce(
flusher, _ := w.(http.Flusher) flusher, _ := w.(http.Flusher)
if len(firstChunk) > 0 { if len(firstChunk) > 0 {
if _, werr := w.Write(firstChunk); werr != nil { if _, werr := w.Write(firstChunk); werr != nil {
return true, false, werr, nil if attemptTrace != nil {
attemptTrace.Result = "downstream_write_error"
attemptTrace.Error = werr.Error()
}
return true, false, false, werr, nil
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@ -335,7 +478,11 @@ func (s *Server) tryOnce(
} }
if n > 0 { if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil { if _, werr := w.Write(buf[:n]); werr != nil {
return true, false, werr, nil if attemptTrace != nil {
attemptTrace.Result = "downstream_write_error"
attemptTrace.Error = werr.Error()
}
return true, false, false, werr, nil
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@ -343,14 +490,29 @@ func (s *Server) tryOnce(
} }
if rerr != nil { if rerr != nil {
if errors.Is(rerr, io.EOF) { if errors.Is(rerr, io.EOF) {
return true, false, nil, nil if attemptTrace != nil {
attemptTrace.Result = "success"
attemptTrace.Success = true
}
return true, true, false, nil, nil
} }
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream body read failed after response start: %v", aliasName, attempt, provider.ID, target.Model, rerr) s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream body read failed after response start: %v", aliasName, attempt, provider.ID, target.Model, rerr)
return true, false, rerr, nil if attemptTrace != nil {
attemptTrace.Result = "stream_error"
attemptTrace.Error = rerr.Error()
}
return true, false, false, rerr, nil
} }
} }
} }
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
var errFirstByteTimeout = errors.New("first byte timeout") var errFirstByteTimeout = errors.New("first byte timeout")
var errStreamIdleTimeout = errors.New("stream idle timeout") var errStreamIdleTimeout = errors.New("stream idle timeout")
@ -448,6 +610,20 @@ func requestReadError(err error) (int, string) {
} }
} }
func timeoutDuration(value int, fallback int) time.Duration {
if value <= 0 {
value = fallback
}
return time.Duration(value) * time.Millisecond
}
func minDuration(a time.Duration, b time.Duration) time.Duration {
if a <= b {
return a
}
return b
}
func normalizeAliasName(model string) string { func normalizeAliasName(model string) string {
prefix := config.AppName + "/" prefix := config.AppName + "/"
if strings.HasPrefix(model, prefix) { if strings.HasPrefix(model, prefix) {

View File

@ -105,6 +105,16 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
} }
traces := srv.traces.List(10)
if len(traces) != 1 {
t.Fatalf("trace count = %d, want 1", len(traces))
}
if !traces[0].Failover || traces[0].FinalProvider != "p2" || traces[0].AttemptCount != 2 {
t.Fatalf("trace = %#v", traces[0])
}
if got := traces[0].RequestHeaders["Authorization"]; got == "Bearer "+config.DefaultLocalAPIKey || got == "" {
t.Fatalf("trace auth header = %q, want masked value", got)
}
} }
func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) { func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
@ -156,10 +166,6 @@ func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
} }
func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) { func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
oldTimeout := streamIdleTimeout
streamIdleTimeout = 30 * time.Millisecond
defer func() { streamIdleTimeout = oldTimeout }()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -176,7 +182,7 @@ func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
defer upstream.Close() defer upstream.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: config.DefaultLocalAPIKey}, Server: config.Server{APIKey: config.DefaultLocalAPIKey, StreamIdleTimeoutMs: 30},
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}}, Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
Aliases: []config.Alias{{ Aliases: []config.Alias{{
Alias: "gpt-5.4", Alias: "gpt-5.4",
@ -201,6 +207,87 @@ func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
} }
} }
func TestHandleResponsesMarksBrokenStreamAsFailure(t *testing.T) {
t.Parallel()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: first\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
hijacker, ok := w.(http.Hijacker)
if !ok {
t.Fatal("response writer does not support hijacking")
}
conn, _, err := hijacker.Hijack()
if err != nil {
t.Fatalf("Hijack() error = %v", err)
}
_ = conn.Close()
}))
defer upstream.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
Aliases: []config.Alias{{
Alias: "gpt-5.4",
Enabled: true,
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}},
}},
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder()
srv.handleResponses(rr, req)
traces := srv.traces.List(10)
if len(traces) != 1 {
t.Fatalf("trace count = %d, want 1", len(traces))
}
if traces[0].Success {
t.Fatalf("trace = %#v, want failed trace", traces[0])
}
if traces[0].Error == "" {
t.Fatalf("trace = %#v, want error", traces[0])
}
if len(traces[0].Attempts) != 1 {
t.Fatalf("attempts = %#v, want 1", traces[0].Attempts)
}
if traces[0].Attempts[0].Success {
t.Fatalf("attempt = %#v, want failed attempt", traces[0].Attempts[0])
}
if traces[0].Attempts[0].Result != "stream_error" {
t.Fatalf("attempt result = %q, want stream_error", traces[0].Attempts[0].Result)
}
}
func TestNewUsesConfiguredTimeouts(t *testing.T) {
t.Parallel()
srv := New(&config.Config{Server: config.Server{
ConnectTimeoutMs: 12000,
ResponseHeaderTimeoutMs: 21000,
FirstByteTimeoutMs: 22000,
RequestReadTimeoutMs: 33000,
StreamIdleTimeoutMs: 70000,
}})
transport, ok := srv.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T", srv.client.Transport)
}
if transport.ResponseHeaderTimeout != 21*time.Second {
t.Fatalf("ResponseHeaderTimeout = %s, want 21s", transport.ResponseHeaderTimeout)
}
}
func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) { func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
t.Parallel() t.Parallel()

260
internal/proxy/traces.go Normal file
View 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:]
}