Compare commits

..

10 Commits

Author SHA1 Message Date
cc3359f773 gui 2026-04-18 20:49:54 +08:00
29d0cabbd1 chore: record journal 2026-04-18 17:02:45 +08:00
8cf1a504d1 chore: record journal 2026-04-18 15:30:15 +08:00
09d0c1f9c6 fix: harden provider discovery flow 2026-04-18 15:28:35 +08:00
d6ab8f24da chore(task): record review remediation 2026-04-18 00:06:55 +08:00
8e5b247ca1 fix: harden proxy and sync flows 2026-04-18 00:06:38 +08:00
51f030fb8c codexreview 2026-04-17 22:27:49 +08:00
db95b8545a chore: record journal 2026-04-17 21:43:36 +08:00
cf3dcec399 refactor(cli): rename tool to ocswitch
Avoid collision with the existing Opswitch product while keeping the repository name as opencode-provider-switch.

This aligns the CLI, provider key, config paths, examples, and Trellis history under one public-facing name.
2026-04-17 21:43:21 +08:00
594ecadfdd fix(cli): align help with README 2026-04-17 19:27:53 +08:00
83 changed files with 10422 additions and 793 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
/dist/ /dist/
*.tmp *.tmp
output output
frontend/dist/
frontend/node_modules/

View File

@ -0,0 +1,167 @@
# Deep Code Review: ocswitch
Date: 2026-04-17
Reviewer: Apale
Scope: `internal/config`, `internal/proxy`, `internal/opencode`, `internal/cli`, `README.md`
Verification: `go test ./...`, `go test -race ./...`
## Summary
本次 review 未发现立即可触发的数据破坏级致命 bug但确认存在 2 个高风险项、4 个中风险项,以及 4 个低风险/改进项。高风险集中在配置文件并发写入和代理入站 body 无读取超时中风险集中在流式连接悬挂、错误语义丢失、OpenCode 默认模型静默写坏,以及默认本地 API Key 在非 loopback 场景下的暴露风险。
## High Risk
### 1. Config/OpenCode save path is not truly safe under concurrent writers
- Files:
- `internal/config/config.go:147-175`
- `internal/opencode/opencode.go:84-97`
- Problem:
- 两处保存逻辑都使用固定临时文件名 `path + ".tmp"`,且没有文件锁。
- `opencode.Save` 还属于 read-modify-write基于旧文件内容生成新内容存在并发 stale read 覆盖问题。
- Trigger:
- 两个 CLI 进程、两个自动化脚本或未来后台流程同时写同一配置文件。
- Impact:
- 临时文件互相覆盖、最后写入者静默丢掉前一个更新、配置内容回退或错乱。
- Recommended fix:
- 抽公共原子写 helper。
- 使用 `os.CreateTemp(dir, base+".*.tmp")` 创建唯一 tmp。
- 写入后 `Sync` 文件、`Close`、`Rename`,然后 `Sync` 父目录。
- 外层增加 advisory file lock。
- `opencode.Save` 必须在锁内完成 read-modify-write避免旧快照覆盖新文件。
### 2. Proxy request body has no read timeout after headers
- Files:
- `internal/proxy/server.go:79-83`
- `internal/proxy/server.go:148-151`
- Problem:
- 仅配置 `ReadHeaderTimeout`body 通过 `io.ReadAll(http.MaxBytesReader(...))` 全量读取,没有 body read timeout。
- Trigger:
- 客户端快速发完 headers然后慢速发送 body 或中途长期停住。
- Impact:
- slowloris 式连接占用,长期占住 goroutine/socket代理容易被低成本拖死。
- Recommended fix:
- 给 `http.Server` 增加 `ReadTimeout`,至少覆盖 header+body 接收阶段。
- 若后续 body 可能变大,再把该值做成配置项。
- 将底层超时统一映射成稳定的客户端错误,而不是直接回传底层 I/O 文本。
## Medium Risk
### 3. Streaming response has no idle timeout once bytes start flowing
- Files:
- `internal/proxy/server.go:61-64`
- `internal/proxy/server.go:307-323`
- Problem:
- `http.Client.Timeout = 0`,流式转发阶段没有任何后续 idle timeout。
- Trigger:
- 上游先返回部分 chunk随后长时间不再发送数据也不主动断开。
- Impact:
- 请求长期挂起,占住上下游连接与 handler累计后形成资源耗尽。
- Recommended fix:
- 非流式请求使用整体超时。
- 流式请求增加“空闲超时”而非“总超时”,超时后取消上游 context 并中断转发。
### 4. Retryable upstream failures are collapsed into a generic 502
- Files:
- `internal/proxy/server.go:216-217`
- `internal/proxy/server.go:257-260`
- Problem:
- 上游 `429`/`5xx` 被当作可重试失败吞掉;所有 target 都失败时统一返回 `502 all upstream targets failed`
- Trigger:
- 单个 target 返回 `429/5xx`,或所有 target 都返回 retryable failure。
- Impact:
- 客户端无法拿到真实 `429`、`Retry-After`、上游错误体;限流与退避语义丢失,排障信息也被抹平。
- Recommended fix:
- `tryOnce` 返回结构化失败信息status/headers/body
- 失败链耗尽且尚未向下游写字节时,优先透传最后一个上游错误,至少保留 `429`、`Retry-After`、`Content-Type` 和裁剪后的 body。
### 5. `opencode sync --set-model` can silently write an invalid default model
- Files:
- `internal/cli/opencode.go:82-93`
- Problem:
- `--set-model``--set-small-model` 不校验输入是否是当前可路由 alias。
- Trigger:
- 用户输入不存在的 alias、已禁用 alias 或不符合 `ocswitch/<alias>` 约定的值。
- Impact:
- `sync` 表面成功,但会把 OpenCode 顶层默认模型写成无效值,形成静默坏配置。
- Recommended fix:
- 仅接受 `ocswitch/<alias>` 形式。
- `<alias>` 必须存在于 `cfg.AvailableAliasNames()`
- 报错时给出候选 alias 列表。
### 6. Fixed default local API key remains unsafe when binding to non-loopback addresses
- Files:
- `internal/config/config.go:18-20`
- `internal/config/config.go:83-89`
- `internal/config/config.go:392-395`
- `internal/proxy/server.go:120-133`
- Problem:
- 默认本地 API Key 为固定公开值 `ocswitch-local`,同时校验逻辑未阻止用户在非 loopback 监听时继续使用默认 key。
- Trigger:
- 用户将 `server.host` 设置为 `0.0.0.0`、局域网 IP、容器映射地址等。
- Impact:
- 网络上任何知道默认 key 的访问者都可直接调用代理并消耗上游额度。
- Recommended fix:
- 在 `Validate()` 中增加约束:非 loopback 监听时拒绝默认 key。
- 更稳妥方案是首次生成随机 key只在纯本机模式允许默认值。
## Low Risk / Improvement Options
### 7. Alias lifecycle is incomplete in CLI
- File: `internal/cli/alias.go:35-84`
- Problem:
- 缺少 `alias enable/disable`alias 一旦禁用后没有 CLI 恢复路径。
- Options:
- 增加 `alias enable` / `alias disable` 子命令。
- 将 `alias add` 改成三态更新:未指定、启用、禁用。
### 8. Import behavior and docs are inconsistent for empty API key
- Files:
- `internal/opencode/opencode.go:581-594`
- `internal/cli/provider.go:273-275`
- Problem:
- 实现允许导入只有 `baseURL`、没有 `apiKey` 的 provider但帮助文本/README 表述为需要两者同时存在。
- Options:
- 收紧实现,要求 `apiKey != ""`
- 或放宽文档,明确允许导入空 key provider。
### 9. `opencode sync` still has broader side effects than users may expect
- Files:
- `internal/opencode/opencode.go:58-60`
- `internal/opencode/opencode.go:80-83`
- `internal/opencode/opencode.go:424-428`
- Problem:
- `opencode sync` 会丢失 JSONC 注释,还可能补写 `$schema`
- Options:
- 在 CLI help/README 中明确副作用。
- 长期方案是引入更细粒度的 JSONC patch 机制,尽量避免无关键重写。
### 10. Header forwarding is still broader than ideal
- Files:
- `internal/proxy/server.go:394-440`
- Problem:
- 目前只做固定 hop-by-hop 过滤,转发策略仍偏黑名单。
- Options:
- 解析 `Connection` 动态声明的 hop-by-hop headers 并移除。
- 逐步收敛成最小白名单转发。
## Testing Notes
- `go test ./...` passed
- `go test -race ./...` passed
## Coverage Gaps Observed
- 未覆盖并发写配置文件的场景
- 未覆盖慢速 body 读取超时场景
- 未覆盖全部上游 `429/5xx` 时的错误透传语义
- 未覆盖 `--set-model` / `--set-small-model` 非法值校验

View File

@ -0,0 +1,26 @@
- Added `.trellis/tasks/04-17-deep-code-review-ocswitch/review.md` to capture the full deep review output and concrete remediation guidance.
- Recorded 2 high-risk items:
- concurrent config/OpenCode save paths are unsafe under concurrent writers
- proxy request body has no read timeout after headers
- Recorded 4 medium-risk items:
- streaming responses have no idle timeout
- retryable upstream failures are collapsed into a generic `502`
- `opencode sync --set-model` / `--set-small-model` can silently write invalid defaults
- fixed default API key remains unsafe when binding to non-loopback addresses
- Recorded 4 low-risk or improvement items:
- alias lifecycle lacks enable/disable recovery path
- provider import docs and implementation disagree on empty `apiKey`
- `opencode sync` side effects on JSONC comments and `$schema` need clearer docs
- header forwarding should better handle dynamic hop-by-hop headers and narrower forwarding rules
- Implemented direct fixes for the clearly scoped items:
- added advisory file locking and atomic writes for both local config and OpenCode sync writes
- validated non-loopback server binds cannot keep the default local API key
- added routable alias validation for `opencode sync --set-model` and `--set-small-model`
- added request body read timeout handling and stable timeout error mapping
- added stream idle timeout and last retryable upstream error passthrough for `429`/`5xx`
- narrowed forwarded request headers conservatively, including dynamic `Connection`-declared hop-by-hop removal
- aligned provider import behavior/docs to allow empty `apiKey`
- documented that OpenCode sync rewrites JSONC as normalized JSON
- Verification completed after implementation:
- `go test ./...`
- `go test -race ./...`

View File

@ -0,0 +1,71 @@
{
"id": "deep-code-review-ocswitch",
"name": "deep-code-review-ocswitch",
"title": "Deep code review for ocswitch",
"description": "Record deep code review findings, risks, and remediation options for the current ocswitch implementation.",
"status": "completed",
"dev_type": "docs",
"scope": "review",
"package": null,
"priority": "P1",
"creator": "Apale",
"assignee": "Apale",
"createdAt": "2026-04-17",
"completedAt": "2026-04-17",
"branch": null,
"base_branch": "master",
"worktree_path": null,
"current_phase": 0,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-17-deep-code-review-ocswitch/review.md",
"internal/config/config.go",
"internal/proxy/server.go",
"internal/opencode/opencode.go",
"internal/cli/opencode.go",
"internal/cli/alias.go",
"internal/cli/provider.go",
"README.md"
],
"notes": "Completed a deep code review and recorded 2 high-risk, 4 medium-risk, and 4 low-risk/improvement findings with concrete remediation guidance.",
"meta": {
"reviewType": "deep-code-review",
"highRiskCount": 2,
"mediumRiskCount": 4,
"lowRiskCount": 4,
"verification": [
"go test ./...",
"go test -race ./..."
]
}
}

View File

@ -1,8 +1,8 @@
# OLPX Forwarding Log Viewer Design # OCSWITCH Forwarding Log Viewer Design
## Summary ## Summary
`olpx` currently exposes enough per-request routing information for header-level debugging, but it does not keep a durable request log and it does not provide any local UI. `ocswitch` currently exposes enough per-request routing information for header-level debugging, but it does not keep a durable request log and it does not provide any local UI.
This task defines a minimal, implementation-ready design for a local web log viewer that shows each forwarded request with: This task defines a minimal, implementation-ready design for a local web log viewer that shows each forwarded request with:
@ -23,18 +23,18 @@ The design is intentionally scoped to the current codebase:
### What already exists ### What already exists
`olpx` already has the core proxy path in `internal/proxy/server.go`: `ocswitch` already has the core proxy path in `internal/proxy/server.go`:
- `POST /v1/responses` - `POST /v1/responses`
- deterministic ordered failover - deterministic ordered failover
- retry on transport errors, `429`, and `5xx` - retry on transport errors, `429`, and `5xx`
- no mid-stream failover after downstream response starts - no mid-stream failover after downstream response starts
- debug response headers: - debug response headers:
- `X-OLPX-Alias` - `X-OCSWITCH-Alias`
- `X-OLPX-Provider` - `X-OCSWITCH-Provider`
- `X-OLPX-Remote-Model` - `X-OCSWITCH-Remote-Model`
- `X-OLPX-Attempt` - `X-OCSWITCH-Attempt`
- `X-OLPX-Failover-Count` - `X-OCSWITCH-Failover-Count`
### What is missing ### What is missing
@ -74,12 +74,12 @@ Reason:
- it fits the current Go-only codebase - it fits the current Go-only codebase
- it avoids adding a frontend toolchain - it avoids adding a frontend toolchain
- it can run off the same `olpx serve` process and listener - it can run off the same `ocswitch serve` process and listener
- it is the smallest path to a usable visual log surface - it is the smallest path to a usable visual log surface
## High-Level Design ## High-Level Design
When `olpx serve` is running, the same HTTP server should expose three surfaces: When `ocswitch serve` is running, the same HTTP server should expose three surfaces:
1. proxy API 1. proxy API
- `POST /v1/responses` - `POST /v1/responses`
@ -116,10 +116,10 @@ This keeps storage simple and makes the UI directly match the user's mental mode
### Recommended MVP storage ### Recommended MVP storage
Use an append-only JSONL file stored next to the existing `olpx` config: Use an append-only JSONL file stored next to the existing `ocswitch` config:
- config path today: `~/.config/olpx/config.json` by default - config path today: `~/.config/ocswitch/config.json` by default
- proposed log path: `~/.config/olpx/request-log.jsonl` - proposed log path: `~/.config/ocswitch/request-log.jsonl`
Reason: Reason:
@ -156,7 +156,7 @@ Each finalized request log entry should contain at least the following fields:
"duration_ms": 1881, "duration_ms": 1881,
"protocol": "openai-responses", "protocol": "openai-responses",
"alias": "gpt-5.4", "alias": "gpt-5.4",
"raw_model": "olpx/gpt-5.4", "raw_model": "ocswitch/gpt-5.4",
"stream": true, "stream": true,
"final_status": 200, "final_status": 200,
"final_provider": "p2", "final_provider": "p2",
@ -195,7 +195,7 @@ Each finalized request log entry should contain at least the following fields:
### Required field semantics ### Required field semantics
- `alias`: normalized local alias actually routed by `olpx` - `alias`: normalized local alias actually routed by `ocswitch`
- `raw_model`: original request payload `model` value before normalization - `raw_model`: original request payload `model` value before normalization
- `final_provider`: provider that produced the downstream response visible to the client - `final_provider`: provider that produced the downstream response visible to the client
- `final_remote_model`: upstream model name actually sent to that provider - `final_remote_model`: upstream model name actually sent to that provider
@ -381,7 +381,7 @@ For MVP, the log viewer can follow the same trust model as the current local pro
- same loopback listener by default - same loopback listener by default
- intended for single-user localhost usage - intended for single-user localhost usage
If `olpx` is later allowed to bind non-loopback addresses, the log UI and log API should be explicitly gated before reuse in that mode. If `ocswitch` is later allowed to bind non-loopback addresses, the log UI and log API should be explicitly gated before reuse in that mode.
## Implementation Plan ## Implementation Plan
@ -438,7 +438,7 @@ Suggested existing files to extend:
This design is considered implemented successfully when: This design is considered implemented successfully when:
1. running `olpx serve` exposes a local page at `/logs` 1. running `ocswitch serve` exposes a local page at `/logs`
2. the page shows one row per completed forwarding request 2. the page shows one row per completed forwarding request
3. each row clearly shows final provider and final upstream model 3. each row clearly shows final provider and final upstream model
4. each row clearly shows whether failover happened 4. each row clearly shows whether failover happened

View File

@ -1,8 +1,8 @@
{ {
"id": "forwarding-log-viewer-gui", "id": "forwarding-log-viewer-gui",
"name": "forwarding-log-viewer-gui", "name": "forwarding-log-viewer-gui",
"title": "Design forwarding log viewer GUI for olpx", "title": "Design forwarding log viewer GUI for ocswitch",
"description": "Define a minimal local web UI and request logging design for olpx forwarding operations, including provider/model visibility, token usage, and failover display.", "description": "Define a minimal local web UI and request logging design for ocswitch forwarding operations, including provider/model visibility, token usage, and failover display.",
"status": "completed", "status": "completed",
"dev_type": "docs", "dev_type": "docs",
"scope": "observability", "scope": "observability",
@ -54,9 +54,25 @@
"internal/cli/serve.go", "internal/cli/serve.go",
"README.md" "README.md"
], ],
"notes": "Design-only task completed. The PRD recommends a same-process local web viewer with append-only JSONL request logs and no new frontend build system.", "notes": "Design-only task completed. The PRD recommends a same-process local web viewer with append-only JSONL request logs and no new frontend build system. Follow-up product discussion on 2026-04-18 introduced explicit desktop requirements (launch at login, native menu/tray, native notifications). That direction should be handled as a separate desktop-shell task instead of rewriting this completed web-UI/log-viewer design task.",
"meta": { "meta": {
"recommendedSurface": "local-web-ui", "recommendedSurface": "local-web-ui",
"desktopFollowupRecommended": true,
"desktopFollowupStack": [
"wails",
"react",
"typescript",
"tailwindcss",
"react-hook-form",
"zod"
],
"desktopArchitecture": [
"internal/config for config IO and validation",
"internal/proxy for proxy runtime",
"internal/opencode for OpenCode sync",
"internal/app as shared application service layer for CLI and GUI",
"desktop shell handles window tray menu notification autostart only"
],
"protocolScope": [ "protocolScope": [
"openai-responses" "openai-responses"
], ],

View File

@ -1,7 +1,7 @@
{ {
"id": "preserve-opencode-model-metadata-sync", "id": "preserve-opencode-model-metadata-sync",
"name": "preserve-opencode-model-metadata-sync", "name": "preserve-opencode-model-metadata-sync",
"title": "Preserve OpenCode model metadata during olpx sync", "title": "Preserve OpenCode model metadata during ocswitch sync",
"description": "", "description": "",
"status": "completed", "status": "completed",
"dev_type": null, "dev_type": null,

View File

@ -0,0 +1,715 @@
# OCSWITCH Desktop Shell Design
## Summary
`ocswitch` is currently a Go CLI and local proxy. It already has the core routing, config persistence, and OpenCode sync logic needed for daily use, but it does not yet provide a desktop-native control surface.
This task defines a desktop-shell architecture for `ocswitch` with these explicit product requirements:
- launch at login
- native tray/menu integration
- native notifications
- a GUI control panel for configuration and runtime status
The key constraint is equally explicit:
- do not rewrite existing Go core logic into desktop-only code
Instead, this task introduces a shared application service layer so the CLI and desktop GUI can reuse the same workflows.
## Background And Why This Is A Separate Task
The completed task `04-17-forwarding-log-viewer-gui` correctly designed a same-process local web log viewer and explicitly excluded Electron or native desktop GUI.
That task remains valid for its original goal.
This task exists because product direction has changed. The GUI now needs real desktop capabilities:
- launch at login
- tray/menu presence
- notifications
- background-style operation
Those requirements justify a desktop shell and should not be retrofitted into the completed local-web-viewer task.
## Product Goal
Provide a desktop-native control surface for `ocswitch` that can:
1. manage providers, aliases, and sync operations through a GUI
2. control and observe local proxy runtime state
3. run comfortably as a background desktop utility
4. expose tray/menu and notification workflows expected from a resident local tool
5. preserve the existing Go codebase as the primary implementation of business rules
## Primary Requirements
### Desktop capabilities
- Launch at login must be supported.
- Native tray/menu integration must be supported.
- Native notifications must be supported.
- The application should be able to behave like a local resident utility, not just a browser page.
### Reuse requirements
- `internal/config` must continue to own config structures, loading, saving, and validation.
- `internal/proxy` must continue to own proxy runtime and failover behavior.
- `internal/opencode` must continue to own OpenCode config sync behavior.
- A new `internal/app` layer must own shared workflows used by both CLI and GUI.
- The desktop shell must not directly reimplement provider/alias/sync rules.
### GUI scope
The first GUI iteration should focus on a control-panel workflow rather than a full analytics product.
Minimum surface:
- overview/status page
- provider management page
- alias management page
- doctor and OpenCode sync page
## Non-Goals
1. No rewrite of config/proxy/opencode logic into desktop-specific modules.
2. No replacement of the existing CLI.
3. No hosted service or remote control plane.
4. No multi-user or authenticated remote administration model.
5. No forced migration away from existing config file formats.
6. No requirement that the initial desktop shell also ships the full request log viewer.
## Technology Recommendation
### Recommended stack
- Desktop shell: `Wails`
- Frontend: `React + TypeScript`
- Styling: `Tailwind CSS`
- Forms and validation: `react-hook-form + zod`
### Why Wails
`ocswitch` already centers on Go. The desktop shell should wrap that Go core rather than forcing a new primary runtime.
Wails is recommended because:
- it matches the existing Go-heavy codebase
- it avoids introducing Rust as another core runtime concern
- it supports the desktop shell product shape better than a browser-only UI
- it allows Go services to remain the real source of business behavior
### Why React over Vue
The GUI is primarily a configuration-heavy control panel.
React is recommended because:
- the form-heavy workflow aligns well with `react-hook-form + zod`
- long-term ecosystem support for control-panel style applications is strong
- it is a pragmatic default when the frontend is not the product center but must remain maintainable as scope grows
Vue is not rejected as incapable; it is simply not the preferred choice for this project's chosen form stack and expected long-term expansion.
## Current Project State
### Existing reusable layers
#### `internal/config`
Already provides:
- `Config`, `Provider`, `Alias`, `Target`, `Server` models
- config load/save
- default path resolution
- provider and alias mutation helpers
- structural validation
#### `internal/proxy`
Already provides:
- local HTTP server
- `/v1/responses`
- `/v1/models`
- deterministic alias failover behavior
- downstream streaming pass-through
#### `internal/opencode`
Already provides:
- OpenCode config load/save
- provider sync patching
- global target path resolution
#### `internal/cli`
Currently provides command entry points, but it also contains business orchestration that should move into a shared application layer.
Examples:
- provider add/update orchestration
- alias bind/unbind orchestration
- doctor orchestration
- OpenCode sync orchestration
- serve command bootstrap
## Proposed Architecture
### Layer model
Recommended layers:
1. `internal/config`
2. `internal/proxy`
3. `internal/opencode`
4. `internal/app`
5. `internal/cli`
6. `internal/desktop`
7. `web/`
### Responsibility boundaries
#### `internal/config`
Owns:
- persisted config structures
- file path resolution
- load/save with locking and atomic write behavior
- config-level validation helpers
Must not own:
- desktop shell behavior
- UI-specific DTOs
- tray/menu integration
#### `internal/proxy`
Owns:
- proxy server construction
- request routing
- upstream retry/failover semantics
- HTTP runtime behavior
Must not own:
- desktop lifecycle decisions
- UI-specific runtime state projection
#### `internal/opencode`
Owns:
- OpenCode config parsing and writing
- `provider.ocswitch` sync logic
- target resolution for the OpenCode config file
Must not own:
- GUI orchestration
- desktop notifications
#### `internal/app`
Owns:
- reusable business workflows
- shared DTOs for CLI and GUI
- orchestration across `config`, `proxy`, and `opencode`
- runtime supervision of the embedded proxy process in desktop mode
Must not own:
- Wails-specific bindings
- frontend rendering
- tray/menu implementation
#### `internal/cli`
Owns:
- cobra command tree
- flag parsing
- terminal printing
Must delegate business workflows to `internal/app`.
#### `internal/desktop`
Owns:
- Wails application bootstrap
- window lifecycle
- tray/menu wiring
- native notifications
- launch-at-login integration
- binding frontend calls to `internal/app`
Must not own:
- provider/alias mutation rules
- OpenCode sync rules
- direct config mutation logic beyond reading desktop preference values via services
#### `web/`
Owns:
- React pages and components
- visual state and form UX
- calling Wails-bound methods
Must not own:
- config file persistence rules
- routing/failover behavior
## Recommended Directory Shape
```text
cmd/
ocswitch/
main.go
ocswitch-desktop/
main.go
internal/
config/
proxy/
opencode/
app/
service.go
types.go
config_service.go
provider_service.go
alias_service.go
sync_service.go
runtime_service.go
cli/
desktop/
app.go
tray.go
menu.go
notify.go
autostart.go
bindings.go
web/
package.json
src/
pages/
components/
lib/
```
The exact filenames can vary, but the boundary itself should remain stable.
## `internal/app` Design
### Purpose
`internal/app` is the shared application service layer.
It exists to solve a current structural problem in the codebase: many workflows already exist, but they are embedded inside CLI command handlers. The desktop GUI should not duplicate those workflows, and the CLI should not remain the only caller.
`internal/app` should therefore become the only place where cross-package use cases are orchestrated.
### Design principles
1. Organize by user workflows, not by storage structs.
2. Return stable DTOs instead of exposing raw persistence objects directly to GUI code.
3. Keep validation and persistence in the existing packages where appropriate.
4. Keep the initial design small and explicit rather than overly abstract.
### Top-level service shape
Recommended root type:
```go
type Service struct {
configPath string
mu sync.Mutex
proxyCancel context.CancelFunc
proxyDone chan struct{}
proxyStatus ProxyStatusView
}
func NewService(configPath string) *Service
```
The root service may either expose all methods directly or hold narrower sub-services internally. The important point is that callers should consume one coherent application boundary.
### Shared DTOs
Recommended DTOs are intentionally UI-safe and transport-safe.
```go
type Overview struct {
ConfigPath string `json:"configPath"`
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
AvailableAliases []string `json:"availableAliases"`
Proxy ProxyStatusView `json:"proxy"`
Desktop DesktopPrefsView `json:"desktop"`
}
type ProxyStatusView struct {
Running bool `json:"running"`
BindAddress string `json:"bindAddress"`
StartedAt time.Time `json:"startedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type ProviderView struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
BaseURL string `json:"baseUrl"`
APIKeySet bool `json:"apiKeySet"`
Headers map[string]string `json:"headers,omitempty"`
Models []string `json:"models,omitempty"`
ModelsSource string `json:"modelsSource,omitempty"`
Disabled bool `json:"disabled"`
}
type AliasTargetView struct {
Provider string `json:"provider"`
Model string `json:"model"`
Enabled bool `json:"enabled"`
}
type AliasView struct {
Alias string `json:"alias"`
DisplayName string `json:"displayName,omitempty"`
Enabled bool `json:"enabled"`
Targets []AliasTargetView `json:"targets"`
Available bool `json:"available"`
}
type DoctorIssue struct {
Message string `json:"message"`
}
type DoctorReport struct {
OK bool `json:"ok"`
Issues []DoctorIssue `json:"issues"`
ConfigPath string `json:"configPath"`
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
ProxyBindAddress string `json:"proxyBindAddress"`
OpenCodeTargetPath string `json:"openCodeTargetPath"`
OpenCodeTargetFound bool `json:"openCodeTargetFound"`
}
type SyncPreview struct {
TargetPath string `json:"targetPath"`
AliasNames []string `json:"aliasNames"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
WouldChange bool `json:"wouldChange"`
}
type SyncResult struct {
TargetPath string `json:"targetPath"`
AliasNames []string `json:"aliasNames"`
Changed bool `json:"changed"`
DryRun bool `json:"dryRun"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
}
type DesktopPrefsView struct {
LaunchAtLogin bool `json:"launchAtLogin"`
MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"`
}
```
### Input types
Recommended command inputs:
```go
type SaveProviderInput struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
ClearHeaders bool `json:"clearHeaders"`
Disabled bool `json:"disabled"`
SkipModels bool `json:"skipModels"`
}
type SaveAliasInput struct {
Alias string `json:"alias"`
DisplayName string `json:"displayName,omitempty"`
Disabled bool `json:"disabled"`
}
type BindAliasTargetInput struct {
Alias string `json:"alias"`
Provider string `json:"provider,omitempty"`
Model string `json:"model"`
Disabled bool `json:"disabled"`
}
type UnbindAliasTargetInput struct {
Alias string `json:"alias"`
Provider string `json:"provider,omitempty"`
Model string `json:"model"`
}
type SyncInput struct {
Target string `json:"target,omitempty"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
DryRun bool `json:"dryRun"`
}
type DesktopPrefsInput struct {
LaunchAtLogin bool `json:"launchAtLogin"`
MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"`
}
```
### Required service methods
At minimum, `internal/app` should expose these use cases:
```go
func (s *Service) GetOverview(ctx context.Context) (Overview, error)
func (s *Service) ListProviders(ctx context.Context) ([]ProviderView, error)
func (s *Service) SaveProvider(ctx context.Context, in SaveProviderInput) (ProviderView, error)
func (s *Service) EnableProvider(ctx context.Context, id string) error
func (s *Service) DisableProvider(ctx context.Context, id string) error
func (s *Service) RemoveProvider(ctx context.Context, id string) error
func (s *Service) ImportProvidersFromOpenCode(ctx context.Context, from string, overwrite bool) ([]ProviderView, error)
func (s *Service) ListAliases(ctx context.Context) ([]AliasView, error)
func (s *Service) SaveAlias(ctx context.Context, in SaveAliasInput) (AliasView, error)
func (s *Service) RemoveAlias(ctx context.Context, alias string) error
func (s *Service) BindAliasTarget(ctx context.Context, in BindAliasTargetInput) error
func (s *Service) UnbindAliasTarget(ctx context.Context, in UnbindAliasTargetInput) error
func (s *Service) RunDoctor(ctx context.Context) (DoctorReport, error)
func (s *Service) PreviewOpenCodeSync(ctx context.Context, in SyncInput) (SyncPreview, error)
func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResult, error)
func (s *Service) StartProxy(ctx context.Context) error
func (s *Service) StopProxy(ctx context.Context) error
func (s *Service) RestartProxy(ctx context.Context) error
func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error)
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error)
func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (DesktopPrefsView, error)
```
### Method behavior notes
#### `SaveProvider`
Must preserve current provider semantics already implemented in CLI orchestration:
- update-or-insert behavior
- optional model discovery
- preservation of fields not explicitly changed
- stale discovered catalog downgrade to untrusted metadata when needed
The GUI must not reimplement any of this itself.
#### `BindAliasTarget`
Must preserve current CLI semantics:
- allow combined `provider/model` parsing when provider is omitted
- validate provider existence
- validate known model when model catalog is trusted
- auto-create alias when current workflow requires it
#### `RunDoctor`
Should centralize the same checks currently wired in the CLI command:
- config validation
- OpenCode target resolution
- provider.ocswitch preview validation
#### Runtime methods
Must make desktop mode safe:
- no duplicate proxy start
- clean shutdown
- observable runtime state
- clear last-error reporting for the GUI and tray status
### Runtime supervision model
Desktop mode requires the proxy to be managed as an internal long-running component.
Recommended runtime model:
```text
desktop shell starts
-> app.Service constructed
-> user or preference requests proxy start
-> app.Service loads config and validates it
-> app.Service creates proxy.Server
-> app.Service starts ListenAndServe under managed context
-> proxy status is updated for GUI/tray visibility
```
Important implementation requirements:
- repeated start requests must be idempotent or return a clear error
- stop must call graceful shutdown through context cancellation
- runtime state must survive UI refreshes within the same process
## Desktop Shell Design
### Recommended `internal/desktop` responsibilities
#### `app.go`
- bootstrap Wails
- construct `internal/app.Service`
- wire lifecycle hooks
#### `bindings.go`
- expose thin Wails-callable methods
- forward all real work to `internal/app`
#### `tray.go`
- tray icon
- quick actions such as show window, start proxy, stop proxy, sync, quit
- tray status updates based on `app.Service` runtime state
#### `menu.go`
- native app menu entries if needed per platform
#### `notify.go`
- success and error notifications for:
- proxy start/stop
- config save failures
- sync results
#### `autostart.go`
- platform-specific launch-at-login registration
- read desired state from desktop preferences exposed through `internal/app`
## Configuration Extensions
Desktop behavior may require a small extension to the persisted config.
Recommended addition:
```go
type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"`
}
```
Then extend `config.Config` with:
```go
Desktop Desktop `json:"desktop,omitempty"`
```
This should remain a lightweight preference store, not a place to encode platform-specific shell details.
## CLI Migration Plan
The CLI should remain as a first-class interface.
Recommended migration pattern:
1. move workflow orchestration from `internal/cli/*.go` into `internal/app`
2. keep cobra commands as thin argument adapters
3. update CLI output formatting without changing business semantics
This task is successful only if CLI and GUI both use the same business paths.
## GUI Surface Recommendation
### Initial pages
1. Overview
2. Providers
3. Aliases
4. Doctor and OpenCode Sync
5. Settings
### Overview page should show
- current config path
- proxy runtime status
- bind address
- provider count
- alias count
- last doctor result summary if available
### Provider page should support
- add provider
- edit provider
- enable/disable provider
- remove provider
- show discovered model metadata summary
### Alias page should support
- add/edit alias
- remove alias
- bind/unbind provider targets
- show failover order clearly
### Doctor and Sync page should support
- run doctor
- preview sync target
- apply sync
- show resolved OpenCode target path
### Settings page should support
- launch at login
- minimize to tray
- notifications preference
- optional auto-start proxy preference in a later iteration
## Acceptance Criteria
This design task is complete when the PRD clearly defines:
1. the desktop product shape and why it is separate from the earlier local-web-viewer task
2. the recommended technology stack
3. the layer boundaries between `config`, `proxy`, `opencode`, `app`, `cli`, `desktop`, and `web`
4. the required `internal/app` service methods and DTO shapes
5. the runtime supervision model for the embedded proxy
6. the desktop-only responsibilities for Wails shell code
7. the first GUI page set and minimum feature scope
## Recommended Next Implementation Task
After this design is accepted, the next implementation task should focus on skeleton wiring only:
1. create `internal/app` with one or two end-to-end migrated workflows
2. create Wails shell bootstrap
3. wire a minimal React frontend
4. expose overview plus provider list through the new shared layer
That first implementation task should avoid trying to deliver every page and every shell integration at once.

View File

@ -0,0 +1,106 @@
{
"id": "desktop-shell-gui",
"name": "desktop-shell-gui",
"title": "Design desktop shell for ocswitch",
"description": "Define a desktop-shell architecture for ocswitch that adds launch-at-login, tray/menu, notifications, and a GUI control surface without rewriting existing Go core logic into desktop-specific code.",
"status": "completed",
"dev_type": "docs",
"scope": "desktop",
"package": null,
"priority": "P2",
"creator": "Apale",
"assignee": "Apale",
"createdAt": "2026-04-18",
"completedAt": "2026-04-18",
"branch": null,
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-18-desktop-shell-gui/prd.md",
"internal/config/config.go",
"internal/config/config_test.go",
"internal/app/types.go",
"internal/app/service.go",
"internal/app/service_test.go",
"internal/proxy/server.go",
"internal/opencode/opencode.go",
"internal/cli/root.go",
"internal/cli/provider.go",
"internal/cli/opencode.go",
"internal/cli/doctor.go",
"internal/cli/serve.go",
"internal/desktop/app.go",
"internal/desktop/bindings.go",
"internal/desktop/runtime_fallback.go",
"internal/desktop/runtime_wails.go",
"internal/desktop/http.go",
"internal/desktop/http_test.go",
"internal/desktop/tray.go",
"internal/desktop/notify.go",
"internal/desktop/autostart.go",
"internal/desktop/autostart_test.go",
"internal/desktop/wails.go",
"cmd/ocswitch-desktop/main_fallback.go",
"cmd/ocswitch-desktop/main_wails.go",
"main_wails.go",
"frontend/assets.go",
"frontend/package.json",
"frontend/package-lock.json",
"frontend/tsconfig.json",
"frontend/tsconfig.node.json",
"frontend/vite.config.ts",
"frontend/index.html",
"frontend/src/main.tsx",
"frontend/src/App.tsx",
"frontend/src/api.ts",
"frontend/src/types.ts",
"frontend/src/env.d.ts",
"frontend/src/styles.css",
"frontend/dist/index.html",
"frontend/dist/assets/"
],
"notes": "Implemented the next desktop-shell iteration with a real Wails integration path and a formal React + TypeScript frontend. The browser fallback shell now serves the same frontend assets as the Wails build, Linux XDG launch-at-login is implemented for real, and close-to-background behavior is wired through the desktop preference model. Verification passed for `npm run build`, `go test ./...`, and `go test -tags desktop_wails ./...`. `wails build -tags desktop_wails -debug` now reaches native compilation and is blocked only by missing Linux system packages (`pkg-config`, plus GTK/WebKit dev packages). Stable public tray and native notification APIs remain unresolved in the pinned Wails version, so those desktop-only pieces are intentionally limited to lifecycle wiring/placeholders rather than private-API integrations.",
"meta": {
"desktopShellRequired": true,
"recommendedShell": "wails",
"recommendedFrontend": "react",
"requiredDesktopCapabilities": [
"launch-at-login",
"tray-menu",
"native-notifications"
],
"sharedServiceLayer": "internal/app"
}
}

View File

@ -1,25 +1,25 @@
# OLPX Agent-First CLI Help System # OCSWITCH Agent-First CLI Help System
## Summary ## Summary
`olpx` already has a working CLI and a reasonably complete README, but its help `ocswitch` already has a working CLI and a reasonably complete README, but its help
surface is still optimized for a human reading source-adjacent docs, not for an surface is still optimized for a human reading source-adjacent docs, not for an
AI agent trying to configure the tool end-to-end with high reliability. AI agent trying to configure the tool end-to-end with high reliability.
This PRD defines a narrow product change: This PRD defines a narrow product change:
- every user-facing `olpx` command must provide `Long` help - every user-facing `ocswitch` command must provide `Long` help
- every user-facing `olpx` command must provide `Example` help - every user-facing `ocswitch` command must provide `Example` help
- help content must be written for **AI agent execution reliability first** - help content must be written for **AI agent execution reliability first**
- manual CLI ergonomics remain important, but are secondary to agent clarity - manual CLI ergonomics remain important, but are secondary to agent clarity
The intended result is that an AI agent can use `olpx --help` and nested The intended result is that an AI agent can use `ocswitch --help` and nested
`--help` output as a trustworthy local interface contract for discovery, `--help` output as a trustworthy local interface contract for discovery,
planning, execution, and recovery during configuration. planning, execution, and recovery during configuration.
## Product Goal ## Product Goal
Make `olpx` self-describing enough that a capable AI agent can complete normal Make `ocswitch` self-describing enough that a capable AI agent can complete normal
configuration workflows without relying on hidden tribal knowledge, source code configuration workflows without relying on hidden tribal knowledge, source code
inspection, or README-only instructions. inspection, or README-only instructions.
@ -27,7 +27,7 @@ inspection, or README-only instructions.
User wants to say, in effect: User wants to say, in effect:
"Configure `olpx` for my providers and aliases, then sync OpenCode and tell me "Configure `ocswitch` for my providers and aliases, then sync OpenCode and tell me
how to run it." how to run it."
The agent should be able to do that safely by reading CLI help output and The agent should be able to do that safely by reading CLI help output and
@ -39,9 +39,9 @@ Current repository state already supports the core workflow:
- add or import upstream providers - add or import upstream providers
- create aliases and bind ordered targets - create aliases and bind ordered targets
- validate config via `olpx doctor` - validate config via `ocswitch doctor`
- sync aliases into OpenCode via `olpx opencode sync` - sync aliases into OpenCode via `ocswitch opencode sync`
- run proxy via `olpx serve` - run proxy via `ocswitch serve`
But the command tree does not yet expose the full operational contract through But the command tree does not yet expose the full operational contract through
help text. In practice this means: help text. In practice this means:
@ -52,7 +52,7 @@ help text. In practice this means:
- the "what to do next" step is often only in README, not in command help - the "what to do next" step is often only in README, not in command help
- an agent may need to infer workflow rules from source code instead of help - an agent may need to infer workflow rules from source code instead of help
This is acceptable for an engineering MVP, but not good enough if `olpx` wants This is acceptable for an engineering MVP, but not good enough if `ocswitch` wants
to recommend agent-driven configuration as a first-class path. to recommend agent-driven configuration as a first-class path.
## Design Principle ## Design Principle
@ -97,22 +97,22 @@ This means slightly longer help is acceptable if it reduces agent mistakes.
All user-facing commands in the current Cobra tree: All user-facing commands in the current Cobra tree:
- `olpx` - `ocswitch`
- `olpx serve` - `ocswitch serve`
- `olpx doctor` - `ocswitch doctor`
- `olpx provider` - `ocswitch provider`
- `olpx provider add` - `ocswitch provider add`
- `olpx provider list` - `ocswitch provider list`
- `olpx provider remove` - `ocswitch provider remove`
- `olpx provider import-opencode` - `ocswitch provider import-opencode`
- `olpx alias` - `ocswitch alias`
- `olpx alias add` - `ocswitch alias add`
- `olpx alias list` - `ocswitch alias list`
- `olpx alias bind` - `ocswitch alias bind`
- `olpx alias unbind` - `ocswitch alias unbind`
- `olpx alias remove` - `ocswitch alias remove`
- `olpx opencode` - `ocswitch opencode`
- `olpx opencode sync` - `ocswitch opencode sync`
### Also In Scope ### Also In Scope
@ -169,7 +169,7 @@ Examples must avoid:
### Requirement 3: Root and group commands must explain workflow position ### Requirement 3: Root and group commands must explain workflow position
Commands like `olpx`, `olpx provider`, `olpx alias`, and `olpx opencode` are not Commands like `ocswitch`, `ocswitch provider`, `ocswitch alias`, and `ocswitch opencode` are not
action commands themselves, but they are decision points for an agent. action commands themselves, but they are decision points for an agent.
Their help must answer: Their help must answer:
@ -184,8 +184,8 @@ Whenever a command writes local config or OpenCode config, help must say so.
Examples: Examples:
- provider commands write `olpx` config - provider commands write `ocswitch` config
- alias commands write `olpx` config - alias commands write `ocswitch` config
- `opencode sync` writes the target OpenCode config unless `--dry-run` - `opencode sync` writes the target OpenCode config unless `--dry-run`
- `doctor` validates statically and does not call upstream providers - `doctor` validates statically and does not call upstream providers
- `serve` starts a long-running local proxy and does not mutate config - `serve` starts a long-running local proxy and does not mutate config
@ -197,7 +197,7 @@ defaults.
Examples of defaults that must appear where relevant: Examples of defaults that must appear where relevant:
- default `olpx` config path via `--config` - default `ocswitch` config path via `--config`
- default OpenCode sync target resolution order - default OpenCode sync target resolution order
- default proxy bind address and API key when describing `serve` or workflows - default proxy bind address and API key when describing `serve` or workflows
@ -209,7 +209,7 @@ config files to chat or expose API keys in logs.
## Content Contract By Command Type ## Content Contract By Command Type
### Root Command: `olpx` ### Root Command: `ocswitch`
`Long` should define the full happy-path workflow: `Long` should define the full happy-path workflow:
@ -229,7 +229,7 @@ config files to chat or expose API keys in logs.
`Long` should explain that providers are upstream OpenAI-compatible endpoints `Long` should explain that providers are upstream OpenAI-compatible endpoints
used by alias targets. It should state that provider definitions live in local used by alias targets. It should state that provider definitions live in local
`olpx` config and are separate from alias routing. `ocswitch` config and are separate from alias routing.
`Example` should include: `Example` should include:
@ -268,7 +268,7 @@ used by agents to confirm imported or saved provider IDs before binding aliases.
`Long` should explain: `Long` should explain:
- removes provider from `olpx` config - removes provider from `ocswitch` config
- does not automatically clean alias references - does not automatically clean alias references
- follow-up `doctor` may fail if aliases still reference removed provider - follow-up `doctor` may fail if aliases still reference removed provider
@ -296,7 +296,7 @@ used by agents to confirm imported or saved provider IDs before binding aliases.
### Group Command: `alias` ### Group Command: `alias`
`Long` should explain aliases as the primary user-facing abstraction exposed to `Long` should explain aliases as the primary user-facing abstraction exposed to
OpenCode as `olpx/<alias>`, and that target order defines failover priority. OpenCode as `ocswitch/<alias>`, and that target order defines failover priority.
`Example` should include: `Example` should include:
@ -366,7 +366,7 @@ OpenCode as `olpx/<alias>`, and that target order defines failover priority.
`Long` should explain: `Long` should explain:
- removes entire alias from local config - removes entire alias from local config
- future `opencode sync` will stop exposing it in `provider.olpx.models` - future `opencode sync` will stop exposing it in `provider.ocswitch.models`
- does not directly remove model selection already set elsewhere in OpenCode - does not directly remove model selection already set elsewhere in OpenCode
`Example` should include: `Example` should include:
@ -391,7 +391,7 @@ OpenCode as `olpx/<alias>`, and that target order defines failover priority.
### Group Command: `opencode` ### Group Command: `opencode`
`Long` should explain that these commands manage the narrow integration boundary `Long` should explain that these commands manage the narrow integration boundary
between `olpx` and OpenCode, and do not attempt full OpenCode config takeover. between `ocswitch` and OpenCode, and do not attempt full OpenCode config takeover.
`Example` should include: `Example` should include:
@ -407,7 +407,7 @@ contract.
- exact write target rules - exact write target rules
- what fields are mutated and not mutated - what fields are mutated and not mutated
- that aliases become `provider.olpx.models` - that aliases become `provider.ocswitch.models`
- meaning of `--dry-run` - meaning of `--dry-run`
- recommended call order around `doctor` - recommended call order around `doctor`
@ -481,7 +481,7 @@ scenarios without README-only dependency.
### Scenario 1: Configure from scratch ### Scenario 1: Configure from scratch
1. inspect `olpx --help` 1. inspect `ocswitch --help`
2. add one or more providers 2. add one or more providers
3. add alias 3. add alias
4. bind targets in order 4. bind targets in order
@ -491,7 +491,7 @@ scenarios without README-only dependency.
### Scenario 2: Import existing OpenCode provider definitions first ### Scenario 2: Import existing OpenCode provider definitions first
1. inspect `olpx provider import-opencode --help` 1. inspect `ocswitch provider import-opencode --help`
2. import supported providers 2. import supported providers
3. list providers 3. list providers
4. create aliases and bindings 4. create aliases and bindings
@ -527,9 +527,9 @@ README should not be the only place where critical command semantics live.
### Functional Acceptance ### Functional Acceptance
1. Every user-facing Cobra command in the current `olpx` tree defines non-empty 1. Every user-facing Cobra command in the current `ocswitch` tree defines non-empty
`Long` and non-empty `Example` help. `Long` and non-empty `Example` help.
2. `olpx --help` and all nested `--help` pages expose enough information for an 2. `ocswitch --help` and all nested `--help` pages expose enough information for an
agent to discover the intended configuration workflow without reading source. agent to discover the intended configuration workflow without reading source.
3. Help text for mutating commands explicitly states what files/config are 3. Help text for mutating commands explicitly states what files/config are
written. written.
@ -593,14 +593,14 @@ duplication becomes genuinely harmful.
These are explicitly out of this task, but compatible with it later: These are explicitly out of this task, but compatible with it later:
- `olpx setup` guided workflow command - `ocswitch setup` guided workflow command
- shell completion tuned for provider/alias names - shell completion tuned for provider/alias names
- structured `--help-format json` for agent-native consumption - structured `--help-format json` for agent-native consumption
- README agent prompt template that mirrors the help contract - README agent prompt template that mirrors the help contract
## Final Product Decision ## Final Product Decision
`olpx` should recommend an **agent-first, CLI-native** configuration workflow. `ocswitch` should recommend an **agent-first, CLI-native** configuration workflow.
The CLI itself must become the most reliable place to learn how to configure the The CLI itself must become the most reliable place to learn how to configure the
tool. `Long` and `Example` are therefore not documentation polish; they are part tool. `Long` and `Example` are therefore not documentation polish; they are part

View File

@ -2,7 +2,7 @@
"id": "04-17-agent-first-cli-help-prd", "id": "04-17-agent-first-cli-help-prd",
"name": "04-17-agent-first-cli-help-prd", "name": "04-17-agent-first-cli-help-prd",
"title": "PRD: agent-first CLI help system", "title": "PRD: agent-first CLI help system",
"description": "Define an agent-first product spec for olpx CLI Long/Example help and related docs behavior", "description": "Define an agent-first product spec for ocswitch CLI Long/Example help and related docs behavior",
"status": "completed", "status": "completed",
"dev_type": "docs", "dev_type": "docs",
"scope": null, "scope": null,
@ -57,7 +57,7 @@
"internal/cli/serve.go", "internal/cli/serve.go",
"README.md" "README.md"
], ],
"notes": "This task defines an agent-first PRD for making olpx CLI help text a reliable configuration interface for AI agents.", "notes": "This task defines an agent-first PRD for making ocswitch CLI help text a reliable configuration interface for AI agents.",
"meta": { "meta": {
"primaryAudience": "ai-agent", "primaryAudience": "ai-agent",
"priorityRule": "agent-first-manual-second", "priorityRule": "agent-first-manual-second",

View File

@ -0,0 +1,53 @@
{
"id": "04-17-align-readme-cli-help",
"name": "04-17-align-readme-cli-help",
"title": "Align README and CLI help",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "Apale",
"assignee": "Apale",
"createdAt": "2026-04-17",
"completedAt": "2026-04-17",
"branch": null,
"base_branch": "master",
"worktree_path": null,
"current_phase": 0,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@ -1,8 +1,8 @@
# OLPX MVP Redesign # OCSWITCH MVP Redesign
## Summary ## Summary
`opencode-provider-switch` (`olpx`) is a local proxy for OpenCode focused on one narrow job: `opencode-provider-switch` (`ocswitch`) is a local proxy for OpenCode focused on one narrow job:
- expose one stable local provider to OpenCode - expose one stable local provider to OpenCode
- let users select logical model aliases instead of concrete upstream models - let users select logical model aliases instead of concrete upstream models
@ -15,7 +15,7 @@ MVP is about one thing: **multi-provider failover behind a stable OpenCode model
## Product Goal ## Product Goal
When a user chooses `olpx/<alias>` inside OpenCode, `olpx` should transparently try the configured upstream targets in priority order until one succeeds, without the user needing to care which provider actually served the request. When a user chooses `ocswitch/<alias>` inside OpenCode, `ocswitch` should transparently try the configured upstream targets in priority order until one succeeds, without the user needing to care which provider actually served the request.
## Core User Need ## Core User Need
@ -56,13 +56,13 @@ User wants:
## Architecture in One Sentence ## Architecture in One Sentence
OpenCode sends `POST /v1/responses` to local provider `olpx`; `olpx` resolves requested alias to an ordered target list and proxies request to first healthy upstream candidate. OpenCode sends `POST /v1/responses` to local provider `ocswitch`; `ocswitch` resolves requested alias to an ordered target list and proxies request to first healthy upstream candidate.
## High-Level Architecture ## High-Level Architecture
```text ```text
OpenCode OpenCode
-> custom provider `olpx` (@ai-sdk/openai) -> custom provider `ocswitch` (@ai-sdk/openai)
-> http://127.0.0.1:9982/v1/responses -> http://127.0.0.1:9982/v1/responses
-> alias resolver -> alias resolver
-> failover engine -> failover engine
@ -77,10 +77,10 @@ OpenCode
MVP should expose exactly one local provider to OpenCode: MVP should expose exactly one local provider to OpenCode:
- provider id: `olpx` - provider id: `ocswitch`
- npm package: `@ai-sdk/openai` - npm package: `@ai-sdk/openai`
- base URL: `http://127.0.0.1:9982/v1` - base URL: `http://127.0.0.1:9982/v1`
- local API key: static placeholder such as `olpx-local` - local API key: static placeholder such as `ocswitch-local`
Conceptual OpenCode config shape: Conceptual OpenCode config shape:
@ -88,12 +88,12 @@ Conceptual OpenCode config shape:
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": { "provider": {
"olpx": { "ocswitch": {
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OPS", "name": "OPS",
"options": { "options": {
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local" "apiKey": "ocswitch-local"
}, },
"models": { "models": {
"gpt-5.4": { "gpt-5.4": {
@ -105,7 +105,7 @@ Conceptual OpenCode config shape:
} }
} }
}, },
"model": "olpx/gpt-5.4" "model": "ocswitch/gpt-5.4"
} }
``` ```
@ -117,23 +117,23 @@ OpenCode source confirms this path is valid.
OpenCode Web/TUI model pickers also read runtime provider state and only surface models from connected providers. OpenCode Web/TUI model pickers also read runtime provider state and only surface models from connected providers.
OpenCode provider loading also merges custom config-defined providers and models into runtime provider state. That means `olpx` does **not** need a special external model catalog protocol for MVP. OpenCode provider loading also merges custom config-defined providers and models into runtime provider state. That means `ocswitch` does **not** need a special external model catalog protocol for MVP.
Simplest MVP path: Simplest MVP path:
- keep alias list in `olpx` - keep alias list in `ocswitch`
- sync alias list into OpenCode `provider.olpx.models` - sync alias list into OpenCode `provider.ocswitch.models`
- make sure `provider.olpx` is valid enough to appear as a connected runtime provider - make sure `provider.ocswitch` is valid enough to appear as a connected runtime provider
- let OpenCode surface `olpx/<alias>` in `/models` and `/model` - let OpenCode surface `ocswitch/<alias>` in `/models` and `/model`
### Important Scope Rule ### Important Scope Rule
MVP should **not** rewrite the entire OpenCode config. MVP should **not** rewrite the entire OpenCode config.
Instead, `olpx` should only support a narrow integration step: Instead, `ocswitch` should only support a narrow integration step:
- ensure `provider.olpx` exists or is updated - ensure `provider.ocswitch` exists or is updated
- optionally sync alias entries into `provider.olpx.models` - optionally sync alias entries into `provider.ocswitch.models`
- optionally let user set `model` or `small_model` manually - optionally let user set `model` or `small_model` manually
This avoids the previous PRD's high-risk install/restore workflow. This avoids the previous PRD's high-risk install/restore workflow.
@ -144,7 +144,7 @@ OpenCode source also confirms config is merged from multiple layers, and lower-p
Implication for MVP: Implication for MVP:
- `olpx opencode sync` must know which config file it is targeting - `ocswitch opencode sync` must know which config file it is targeting
- MVP should not silently write one low-precedence file and assume aliases will appear at runtime - MVP should not silently write one low-precedence file and assume aliases will appear at runtime
- default target should be global user config under `~/.config/opencode/` - default target should be global user config under `~/.config/opencode/`
- if global config already exists, reuse existing main file in this order: `opencode.jsonc`, `opencode.json`, `config.json` - if global config already exists, reuse existing main file in this order: `opencode.jsonc`, `opencode.json`, `config.json`
@ -153,11 +153,11 @@ Implication for MVP:
## Provider Source Model ## Provider Source Model
`olpx` needs upstream provider definitions, but this is no longer the product center. `ocswitch` needs upstream provider definitions, but this is no longer the product center.
MVP should support two input paths: MVP should support two input paths:
1. manual provider entry through `olpx` CLI 1. manual provider entry through `ocswitch` CLI
2. one-shot import from one explicit OpenCode config file, defaulting to global user config 2. one-shot import from one explicit OpenCode config file, defaulting to global user config
### Supported Provider Shape In MVP ### Supported Provider Shape In MVP
@ -179,7 +179,7 @@ OpenCode source also shows that `auth.json` provides credentials for an existing
Implication for MVP: Implication for MVP:
- `olpx` should import provider definitions from config, not from merged runtime auth state - `ocswitch` should import provider definitions from config, not from merged runtime auth state
- `auth.json` support, if ever added later, should be treated as credential enrichment for already-known provider IDs - `auth.json` support, if ever added later, should be treated as credential enrichment for already-known provider IDs
- MVP import does not need to evaluate account config, managed config, or remote well-known config layers - MVP import does not need to evaluate account config, managed config, or remote well-known config layers
@ -200,8 +200,8 @@ User should use alias directly inside OpenCode. User should not need to know con
1. Every alias maps to one or more concrete targets. 1. Every alias maps to one or more concrete targets.
2. Every alias must contain at least one enabled target. 2. Every alias must contain at least one enabled target.
3. Alias target order is explicit and defines failover priority. 3. Alias target order is explicit and defines failover priority.
4. Alias name must be unique within `olpx`. 4. Alias name must be unique within `ocswitch`.
5. OpenCode should reference alias as `olpx/<alias>`. 5. OpenCode should reference alias as `ocswitch/<alias>`.
### Example ### Example
@ -234,7 +234,7 @@ Rich capability metadata such as `limit`, `attachment`, `reasoning`, `tool_call`
1. Receive request from OpenCode. 1. Receive request from OpenCode.
2. Read and buffer full JSON request body once. 2. Read and buffer full JSON request body once.
3. Parse `model` from that JSON body. 3. Parse `model` from that JSON body.
4. Treat `model` as `olpx` alias. 4. Treat `model` as `ocswitch` alias.
5. Resolve alias to ordered enabled targets. 5. Resolve alias to ordered enabled targets.
6. Replace only alias model field with concrete upstream model ID. 6. Replace only alias model field with concrete upstream model ID.
7. Forward request to highest-priority target. 7. Forward request to highest-priority target.
@ -303,13 +303,13 @@ Conservative failover is preferable to surprising failover.
## Proxy Debugging Headers ## Proxy Debugging Headers
For debugging, `olpx` should add response headers where possible: For debugging, `ocswitch` should add response headers where possible:
- `X-OLPX-Alias` - `X-OCSWITCH-Alias`
- `X-OLPX-Provider` - `X-OCSWITCH-Provider`
- `X-OLPX-Remote-Model` - `X-OCSWITCH-Remote-Model`
- `X-OLPX-Attempt` - `X-OCSWITCH-Attempt`
- `X-OLPX-Failover-Count` - `X-OCSWITCH-Failover-Count`
These headers are cheap and make failover behavior understandable. These headers are cheap and make failover behavior understandable.
@ -321,7 +321,7 @@ MVP should prefer a simpler user-editable config shape unless implementation pro
### Recommended MVP Direction ### Recommended MVP Direction
Use one local `olpx` JSON or JSONC config file for: Use one local `ocswitch` JSON or JSONC config file for:
- upstream providers - upstream providers
- aliases - aliases
@ -341,19 +341,19 @@ Recommended MVP commands:
### Core ### Core
- `olpx serve` - `ocswitch serve`
- `olpx doctor` - `ocswitch doctor`
### `olpx doctor` MVP Boundary ### `ocswitch doctor` MVP Boundary
First-release `olpx doctor` should stay side-effect free. First-release `ocswitch doctor` should stay side-effect free.
It should validate: It should validate:
- local `olpx` config can be loaded - local `ocswitch` config can be loaded
- every alias resolves to at least one enabled target - every alias resolves to at least one enabled target
- local proxy bind address and config are internally consistent - local proxy bind address and config are internally consistent
- generated or synced `provider.olpx` config shape is structurally valid - generated or synced `provider.ocswitch` config shape is structurally valid
It should not, by default: It should not, by default:
@ -361,35 +361,35 @@ It should not, by default:
- consume quota from user providers - consume quota from user providers
- mutate local or OpenCode config as part of diagnosis - mutate local or OpenCode config as part of diagnosis
If live upstream probing is needed later, it should be added as explicit opt-in behavior such as `olpx doctor --live`. If live upstream probing is needed later, it should be added as explicit opt-in behavior such as `ocswitch doctor --live`.
### Provider Management ### Provider Management
- `olpx provider add` - `ocswitch provider add`
- `olpx provider list` - `ocswitch provider list`
- `olpx provider remove` - `ocswitch provider remove`
- `olpx provider import-opencode` - `ocswitch provider import-opencode`
### Alias Management ### Alias Management
- `olpx alias add` - `ocswitch alias add`
- `olpx alias list` - `ocswitch alias list`
- `olpx alias bind` - `ocswitch alias bind`
- `olpx alias unbind` - `ocswitch alias unbind`
- `olpx alias remove` - `ocswitch alias remove`
### OpenCode Integration ### OpenCode Integration
- `olpx opencode sync` - `ocswitch opencode sync`
## `olpx opencode sync` Responsibility ## `ocswitch opencode sync` Responsibility
This command should do one narrow job: This command should do one narrow job:
- by default update or create custom provider `olpx` in global OpenCode user config - by default update or create custom provider `ocswitch` in global OpenCode user config
- prefer existing global config file in this order: `opencode.jsonc`, `opencode.json`, `config.json` - prefer existing global config file in this order: `opencode.jsonc`, `opencode.json`, `config.json`
- if none exists, create `~/.config/opencode/opencode.jsonc` - if none exists, create `~/.config/opencode/opencode.jsonc`
- sync current alias names into `provider.olpx.models` - sync current alias names into `provider.ocswitch.models`
Optional extra behavior: Optional extra behavior:
@ -418,15 +418,15 @@ Conclusion:
- alias exposure inside OpenCode is feasible in MVP - alias exposure inside OpenCode is feasible in MVP
- simplest path is config sync, not custom remote model registry work - simplest path is config sync, not custom remote model registry work
- syncing alias keys into `provider.olpx.models` is sufficient for exposure if `provider.olpx` lands in connected runtime provider state - syncing alias keys into `provider.ocswitch.models` is sufficient for exposure if `provider.ocswitch` lands in connected runtime provider state
## Security Model ## Security Model
MVP security posture should stay simple: MVP security posture should stay simple:
- listen on `127.0.0.1` by default - listen on `127.0.0.1` by default
- use static local placeholder API key between OpenCode and `olpx` - use static local placeholder API key between OpenCode and `ocswitch`
- store upstream credentials in local `olpx` config - store upstream credentials in local `ocswitch` config
- document that local credential storage is sensitive - document that local credential storage is sensitive
No multi-user or remote-network security guarantees in MVP. No multi-user or remote-network security guarantees in MVP.
@ -437,9 +437,9 @@ MVP is successful if a user can:
1. configure at least two upstream providers manually or through OpenCode sync 1. configure at least two upstream providers manually or through OpenCode sync
2. create an alias with ordered targets across those providers 2. create an alias with ordered targets across those providers
3. run `olpx opencode sync` and see alias names appear in OpenCode `opencode models` output and `/model` picker 3. run `ocswitch opencode sync` and see alias names appear in OpenCode `opencode models` output and `/model` picker
4. select `olpx/<alias>` in OpenCode without exposing concrete upstream model IDs 4. select `ocswitch/<alias>` in OpenCode without exposing concrete upstream model IDs
5. send normal streaming OpenAI Responses traffic through `olpx` 5. send normal streaming OpenAI Responses traffic through `ocswitch`
6. get automatic failover when primary provider returns `429` or `5xx`, or fails before first downstream byte 6. get automatic failover when primary provider returns `429` or `5xx`, or fails before first downstream byte
7. observe that once a stream has started, later upstream failure is surfaced as failure rather than hidden mid-stream switching 7. observe that once a stream has started, later upstream failure is surfaced as failure rather than hidden mid-stream switching
@ -454,10 +454,10 @@ MVP is successful if a user can:
### Phase 2 ### Phase 2
- `olpx opencode sync` - `ocswitch opencode sync`
- narrow OpenCode provider import - narrow OpenCode provider import
- alias list exposure in OpenCode config - alias list exposure in OpenCode config
- connected-provider validation in `olpx doctor` - connected-provider validation in `ocswitch doctor`
### Phase 3 ### Phase 3
@ -469,16 +469,16 @@ MVP is successful if a user can:
### Phase 4 ### Phase 4
- `olpx doctor` - `ocswitch doctor`
- debugging headers and logs - debugging headers and logs
## Finalized MVP Decisions ## Finalized MVP Decisions
The following implementation choices are now locked for first-release MVP: The following implementation choices are now locked for first-release MVP:
1. `olpx opencode sync` updates `provider.olpx` and alias exposure only by default. It must not modify OpenCode `model` or `small_model` unless explicit opt-in flags are provided. 1. `ocswitch opencode sync` updates `provider.ocswitch` and alias exposure only by default. It must not modify OpenCode `model` or `small_model` unless explicit opt-in flags are provided.
2. Provider import support is limited to config-defined `@ai-sdk/openai` custom providers. 2. Provider import support is limited to config-defined `@ai-sdk/openai` custom providers.
3. `olpx doctor` is static by default and must not issue live upstream requests unless future explicit opt-in behavior is added. 3. `ocswitch doctor` is static by default and must not issue live upstream requests unless future explicit opt-in behavior is added.
## Strong Recommendation ## Strong Recommendation

View File

@ -1,8 +1,8 @@
# OLPX MVP Design # OCSWITCH MVP Design
## Summary ## Summary
`opencode-provider-switch` (`olpx`) is a local CLI + proxy for OpenCode. `opencode-provider-switch` (`ocswitch`) is a local CLI + proxy for OpenCode.
Its job is narrow: Its job is narrow:
@ -10,7 +10,7 @@ Its job is narrow:
- Route requests by protocol, not by provider brand - Route requests by protocol, not by provider brand
- Retry/fail over across unreliable upstream relay providers - Retry/fail over across unreliable upstream relay providers
- Let multiple upstream providers share one logical model alias - Let multiple upstream providers share one logical model alias
- Manage state in SQLite, not in an `olpx` config file - Manage state in SQLite, not in an `ocswitch` config file
- Rewrite OpenCode global config to point at the local proxy - Rewrite OpenCode global config to point at the local proxy
MVP intentionally does **not** try to become a general AI gateway, a dashboard product, or a provider-agnostic orchestration platform. MVP intentionally does **not** try to become a general AI gateway, a dashboard product, or a provider-agnostic orchestration platform.
@ -32,7 +32,7 @@ MVP intentionally does **not** try to become a general AI gateway, a dashboard p
## Product Goals ## Product Goals
1. Keep OpenCode usable when cheap relay providers fail intermittently. 1. Keep OpenCode usable when cheap relay providers fail intermittently.
2. Reduce OpenCode config complexity by centralizing provider management in `olpx`. 2. Reduce OpenCode config complexity by centralizing provider management in `ocswitch`.
3. Make failover behavior predictable, visible, and debuggable. 3. Make failover behavior predictable, visible, and debuggable.
4. Preserve OpenCode-native workflow instead of asking users to switch tools. 4. Preserve OpenCode-native workflow instead of asking users to switch tools.
@ -57,9 +57,9 @@ Go is the right fit for this product shape:
## Core Design Principle ## Core Design Principle
`olpx` should manage **protocol pools** and **logical aliases**, not raw provider selection inside OpenCode. `ocswitch` should manage **protocol pools** and **logical aliases**, not raw provider selection inside OpenCode.
OpenCode should see a small number of local proxy providers. `olpx` should own: OpenCode should see a small number of local proxy providers. `ocswitch` should own:
- upstream providers - upstream providers
- API keys and headers - API keys and headers
@ -74,7 +74,7 @@ This keeps OpenCode config stable even when upstream providers are added, remove
```text ```text
OpenCode OpenCode
-> local OpenAI-compatible provider config -> local OpenAI-compatible provider config
-> olpx proxy (127.0.0.1:9982) -> ocswitch proxy (127.0.0.1:9982)
-> protocol router -> protocol router
-> alias resolver -> alias resolver
-> failover engine -> failover engine
@ -127,18 +127,18 @@ OpenCode
Even though both protocols are OpenAI-family APIs, they are **not** interchangeable at the OpenCode config level. Even though both protocols are OpenAI-family APIs, they are **not** interchangeable at the OpenCode config level.
For MVP, `olpx` should expose two local providers to OpenCode: For MVP, `ocswitch` should expose two local providers to OpenCode:
- one chat-completions provider - one chat-completions provider
- one responses provider - one responses provider
This mirrors how OpenCode expects provider wiring today and avoids hidden protocol translation logic inside `olpx`. This mirrors how OpenCode expects provider wiring today and avoids hidden protocol translation logic inside `ocswitch`.
## OpenCode Integration Strategy ## OpenCode Integration Strategy
### Generated OpenCode Config ### Generated OpenCode Config
`olpx install` should rewrite the user's global OpenCode config so that OpenCode points to local proxy providers instead of raw upstream relays. `ocswitch install` should rewrite the user's global OpenCode config so that OpenCode points to local proxy providers instead of raw upstream relays.
Generated provider shape should be conceptually like this: Generated provider shape should be conceptually like this:
@ -146,12 +146,12 @@ Generated provider shape should be conceptually like this:
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": { "provider": {
"olpx-chat": { "ocswitch-chat": {
"npm": "@ai-sdk/openai-compatible", "npm": "@ai-sdk/openai-compatible",
"name": "OLPX Chat", "name": "OCSWITCH Chat",
"options": { "options": {
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local" "apiKey": "ocswitch-local"
}, },
"models": { "models": {
"gpt-5.4": { "gpt-5.4": {
@ -159,12 +159,12 @@ Generated provider shape should be conceptually like this:
} }
} }
}, },
"olpx-responses": { "ocswitch-responses": {
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OLPX Responses", "name": "OCSWITCH Responses",
"options": { "options": {
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local" "apiKey": "ocswitch-local"
}, },
"models": { "models": {
"gpt-5.4": { "gpt-5.4": {
@ -173,16 +173,16 @@ Generated provider shape should be conceptually like this:
} }
} }
}, },
"model": "olpx-responses/gpt-5.4", "model": "ocswitch-responses/gpt-5.4",
"small_model": "olpx-chat/gpt-5.4-mini" "small_model": "ocswitch-chat/gpt-5.4-mini"
} }
``` ```
### Important Rule ### Important Rule
`olpx` should preserve as much of the user's existing OpenCode config as possible. `ocswitch` should preserve as much of the user's existing OpenCode config as possible.
`olpx install` should: `ocswitch install` should:
1. Back up current global config file. 1. Back up current global config file.
2. Import provider/model information relevant to migration. 2. Import provider/model information relevant to migration.
@ -196,12 +196,12 @@ Generated provider shape should be conceptually like this:
OpenCode merges config from multiple sources, and project config can override global config. OpenCode merges config from multiple sources, and project config can override global config.
That means `olpx install` cannot guarantee full interception if a repository-local `opencode.json` or environment override replaces provider/model settings. That means `ocswitch install` cannot guarantee full interception if a repository-local `opencode.json` or environment override replaces provider/model settings.
MVP response: MVP response:
- document this clearly - document this clearly
- make `olpx doctor` detect likely overrides - make `ocswitch doctor` detect likely overrides
- support global install first - support global install first
Do **not** promise perfect takeover across all OpenCode precedence layers in MVP. Do **not** promise perfect takeover across all OpenCode precedence layers in MVP.
@ -210,7 +210,7 @@ Do **not** promise perfect takeover across all OpenCode precedence layers in MVP
### Input Sources ### Input Sources
`olpx install` should inspect: `ocswitch install` should inspect:
1. `~/.config/opencode/opencode.json` 1. `~/.config/opencode/opencode.json`
2. `~/.config/opencode/opencode.jsonc` 2. `~/.config/opencode/opencode.jsonc`
@ -236,7 +236,7 @@ For each imported provider/model entry from OpenCode:
- tool_call - tool_call
- options - options
- variants - variants
5. Generate local `olpx-*` providers for OpenCode. 5. Generate local `ocswitch-*` providers for OpenCode.
### Protocol Classification ### Protocol Classification
@ -246,18 +246,18 @@ Import classification rules for MVP:
- `@ai-sdk/openai` -> `openai-responses` - `@ai-sdk/openai` -> `openai-responses`
- everything else -> unsupported for automatic migration in MVP - everything else -> unsupported for automatic migration in MVP
If unsupported providers exist, `olpx install` should warn and skip them instead of guessing. If unsupported providers exist, `ocswitch install` should warn and skip them instead of guessing.
## SQLite-First State Model ## SQLite-First State Model
`olpx` should not keep its own user-editable config file. `ocswitch` should not keep its own user-editable config file.
Recommended database path: Recommended database path:
- Linux/WSL: `~/.local/share/olpx/olpx.db` - Linux/WSL: `~/.local/share/ocswitch/ocswitch.db`
- Windows native: use `os.UserConfigDir()` or `os.UserCacheDir()`-appropriate app path, finalized in implementation - Windows native: use `os.UserConfigDir()` or `os.UserCacheDir()`-appropriate app path, finalized in implementation
The generated OpenCode config file is an output artifact, not `olpx` source of truth. The generated OpenCode config file is an output artifact, not `ocswitch` source of truth.
### Proposed Tables ### Proposed Tables
@ -368,7 +368,7 @@ Example:
- responses target priority 2: provider `codex-for-me`, remote model `GPT-5.4` - responses target priority 2: provider `codex-for-me`, remote model `GPT-5.4`
- chat target priority 1: provider `relay-x`, remote model `gpt-5.4-chat` - chat target priority 1: provider `relay-x`, remote model `gpt-5.4-chat`
This allows the user to keep using one stable model name inside OpenCode while `olpx` handles provider-specific naming. This allows the user to keep using one stable model name inside OpenCode while `ocswitch` handles provider-specific naming.
### Important Constraint ### Important Constraint
@ -386,13 +386,13 @@ Reduce manual provider setup work.
### Behavior ### Behavior
`olpx` should be able to call upstream `GET /v1/models` and cache results per provider. `ocswitch` should be able to call upstream `GET /v1/models` and cache results per provider.
Useful commands: Useful commands:
- `olpx provider models sync <provider>` - `ocswitch provider models sync <provider>`
- `olpx provider models list <provider>` - `ocswitch provider models list <provider>`
- `olpx alias suggest <provider>` - `ocswitch alias suggest <provider>`
### Important Limitation ### Important Limitation
@ -432,7 +432,7 @@ Same as above, with one critical rule:
- failover is only allowed **before first upstream response byte is sent to the client** - failover is only allowed **before first upstream response byte is sent to the client**
Once a stream begins successfully, `olpx` must stay on that provider for that request. Once a stream begins successfully, `ocswitch` must stay on that provider for that request.
## Failover Policy ## Failover Policy
@ -477,12 +477,12 @@ This is conservative, but predictable.
For debugging, add response headers when possible: For debugging, add response headers when possible:
- `X-OLPX-Protocol` - `X-OCSWITCH-Protocol`
- `X-OLPX-Alias` - `X-OCSWITCH-Alias`
- `X-OLPX-Provider` - `X-OCSWITCH-Provider`
- `X-OLPX-Remote-Model` - `X-OCSWITCH-Remote-Model`
- `X-OLPX-Attempt` - `X-OCSWITCH-Attempt`
- `X-OLPX-Failover-Count` - `X-OCSWITCH-Failover-Count`
These headers are low-cost and help explain behavior fast. These headers are low-cost and help explain behavior fast.
@ -493,7 +493,7 @@ MVP security posture should be intentionally modest but clear.
### Default Behavior ### Default Behavior
- bind only to `127.0.0.1` - bind only to `127.0.0.1`
- generated OpenCode config uses local static API key like `olpx-local` - generated OpenCode config uses local static API key like `ocswitch-local`
- proxy accepts only loopback traffic by default - proxy accepts only loopback traffic by default
### Why This Is Acceptable For MVP ### Why This Is Acceptable For MVP
@ -528,40 +528,40 @@ Proposed command set:
### Lifecycle ### Lifecycle
- `olpx init` - `ocswitch init`
- `olpx serve` - `ocswitch serve`
- `olpx doctor` - `ocswitch doctor`
- `olpx install` - `ocswitch install`
- `olpx restore` - `ocswitch restore`
### Provider Management ### Provider Management
- `olpx provider add` - `ocswitch provider add`
- `olpx provider list` - `ocswitch provider list`
- `olpx provider edit` - `ocswitch provider edit`
- `olpx provider remove` - `ocswitch provider remove`
- `olpx provider enable` - `ocswitch provider enable`
- `olpx provider disable` - `ocswitch provider disable`
### Model Discovery ### Model Discovery
- `olpx provider models sync <provider>` - `ocswitch provider models sync <provider>`
- `olpx provider models list <provider>` - `ocswitch provider models list <provider>`
### Alias Management ### Alias Management
- `olpx alias add` - `ocswitch alias add`
- `olpx alias list` - `ocswitch alias list`
- `olpx alias bind` - `ocswitch alias bind`
- `olpx alias unbind` - `ocswitch alias unbind`
- `olpx alias enable` - `ocswitch alias enable`
- `olpx alias disable` - `ocswitch alias disable`
- `olpx alias inspect <alias>` - `ocswitch alias inspect <alias>`
### Diagnostics ### Diagnostics
- `olpx logs tail` - `ocswitch logs tail`
- `olpx route test --protocol <protocol> --model <alias>` - `ocswitch route test --protocol <protocol> --model <alias>`
## Recommended Minimal UX ## Recommended Minimal UX
@ -569,20 +569,20 @@ Prefer explicit CLI over magical automation.
Good path: Good path:
1. `olpx init` 1. `ocswitch init`
2. `olpx provider add` 2. `ocswitch provider add`
3. `olpx provider models sync` 3. `ocswitch provider models sync`
4. `olpx alias add` 4. `ocswitch alias add`
5. `olpx alias bind` 5. `ocswitch alias bind`
6. `olpx install` 6. `ocswitch install`
7. `olpx serve` 7. `ocswitch serve`
This is easy to explain and easy to debug. This is easy to explain and easy to debug.
## Suggested Go Package Layout ## Suggested Go Package Layout
```text ```text
cmd/olpx/ cmd/ocswitch/
internal/cli/ internal/cli/
internal/db/ internal/db/
internal/models/ internal/models/
@ -608,12 +608,12 @@ Avoid adding a heavy HTTP framework unless a real need appears.
## Generated OpenCode Provider Strategy ## Generated OpenCode Provider Strategy
MVP should generate only providers that `olpx` can actually back. MVP should generate only providers that `ocswitch` can actually back.
That means: That means:
- `olpx-chat` - `ocswitch-chat`
- `olpx-responses` - `ocswitch-responses`
Do **not** generate fake Anthropic provider entries in MVP. Do **not** generate fake Anthropic provider entries in MVP.
@ -621,7 +621,7 @@ Do **not** attempt to preserve original provider IDs inside OpenCode after insta
Reason: Reason:
- `olpx` becomes the stable local provider boundary - `ocswitch` becomes the stable local provider boundary
- upstream providers should move into SQLite management only - upstream providers should move into SQLite management only
## WSL / Windows Strategy ## WSL / Windows Strategy
@ -632,18 +632,18 @@ This requirement is important, but it needs careful wording.
1. Native Linux/WSL build works. 1. Native Linux/WSL build works.
2. Native Windows build works. 2. Native Windows build works.
3. Running OpenCode and `olpx` in the **same environment** is supported. 3. Running OpenCode and `ocswitch` in the **same environment** is supported.
4. `olpx doctor` helps detect config-path and loopback issues. 4. `ocswitch doctor` helps detect config-path and loopback issues.
### What MVP Should Not Promise Yet ### What MVP Should Not Promise Yet
1. Fully automatic cross-boundary migration between WSL OpenCode and Windows `olpx`. 1. Fully automatic cross-boundary migration between WSL OpenCode and Windows `ocswitch`.
2. Transparent path translation for every user setup. 2. Transparent path translation for every user setup.
3. Zero-config interop when OpenCode runs on one side and proxy on the other. 3. Zero-config interop when OpenCode runs on one side and proxy on the other.
### Practical Recommendation ### Practical Recommendation
For MVP, recommend users run OpenCode and `olpx` in the same environment. For MVP, recommend users run OpenCode and `ocswitch` in the same environment.
Cross-environment support can be added later via explicit install target flags. Cross-environment support can be added later via explicit install target flags.
@ -655,7 +655,7 @@ Global config rewrite alone may not capture project-level overrides.
Mitigation: Mitigation:
- `olpx doctor` - `ocswitch doctor`
- clear docs - clear docs
- possible future `ops install --project` - possible future `ops install --project`
@ -704,7 +704,7 @@ MVP is successful if a user can:
1. Import existing OpenCode provider setup into `ops`. 1. Import existing OpenCode provider setup into `ops`.
2. Create or verify aliases for commonly used models. 2. Create or verify aliases for commonly used models.
3. Install proxy-backed OpenCode global config. 3. Install proxy-backed OpenCode global config.
4. Run `olpx serve`. 4. Run `ocswitch serve`.
5. Use OpenCode normally against `127.0.0.1:9982`. 5. Use OpenCode normally against `127.0.0.1:9982`.
6. Survive common upstream failures by automatic provider failover. 6. Survive common upstream failures by automatic provider failover.
@ -734,7 +734,7 @@ MVP is successful if a user can:
- streaming support - streaming support
- logging/diagnostics - logging/diagnostics
- `olpx doctor` - `ocswitch doctor`
- restore flow - restore flow
## Strong Recommendation ## Strong Recommendation

View File

@ -1,7 +1,7 @@
{ {
"id": "provider-disable-failover", "id": "provider-disable-failover",
"name": "provider-disable-failover", "name": "provider-disable-failover",
"title": "Support disabling providers in olpx", "title": "Support disabling providers in ocswitch",
"description": "", "description": "",
"status": "completed", "status": "completed",
"dev_type": null, "dev_type": null,

View File

@ -8,8 +8,8 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 5 - **Total Sessions**: 12
- **Last Active**: 2026-04-17 - **Last Active**: 2026-04-18
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
--- ---
@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~216 | Active | | `journal-1.md` | ~525 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@ -29,9 +29,16 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 12 | 2026-04-18 | Wails desktop shell integration | - | `master` |
| 11 | 2026-04-18 | Desktop control panel implementation | - | `master` |
| 10 | 2026-04-18 | Desktop shell skeleton implementation | - | `master` |
| 9 | 2026-04-18 | GUI desktop direction and architecture decision | - | `master` |
| 8 | 2026-04-18 | Provider discovery and model ref hardening | `09d0c1f` | `master` |
| 7 | 2026-04-17 | Deep code review for ocswitch | - | `master` |
| 6 | 2026-04-17 | Rename CLI to ocswitch | `cf3dcec` | `master` |
| 5 | 2026-04-17 | Fix sync command panic | `887eb14` | `master` | | 5 | 2026-04-17 | Fix sync command panic | `887eb14` | `master` |
| 4 | 2026-04-17 | Review MVP completion status | - | `master` | | 4 | 2026-04-17 | Review MVP completion status | - | `master` |
| 3 | 2026-04-17 | Support disabling providers in olpx | HEAD | `master` | | 3 | 2026-04-17 | Support disabling providers in ocswitch | HEAD | `master` |
| 2 | 2026-04-17 | Build OPS MVP failover proxy | `2b04d91` | `master` | | 2 | 2026-04-17 | Build OPS MVP failover proxy | `2b04d91` | `master` |
| 1 | 2026-04-17 | Finalize OPS MVP review PRD | `eeacdc4`, `00747fa`, `ca0b3c4` | `master` | | 1 | 2026-04-17 | Finalize OPS MVP review PRD | `eeacdc4`, `00747fa`, `ca0b3c4` | `master` |
<!-- @@@/auto:session-history --> <!-- @@@/auto:session-history -->

View File

@ -91,10 +91,10 @@ Implemented the Go-based OPS MVP: local config, provider and alias CLI, OpenCode
- None - task complete - None - task complete
## Session 3: Support disabling providers in olpx ## Session 3: Support disabling providers in ocswitch
**Date**: 2026-04-17 **Date**: 2026-04-17
**Task**: Support disabling providers in olpx **Task**: Support disabling providers in ocswitch
**Branch**: `master` **Branch**: `master`
### Summary ### Summary
@ -139,7 +139,7 @@ Reviewed current implementation against the archived MVP PRD and classified impl
| Category | Result | | Category | Result |
|---|---| |---|---|
| Overall status | MVP core flow is effectively complete | | Overall status | MVP core flow is effectively complete |
| Implemented MVP | Local olpx config, provider/alias CLI, OpenCode sync, static doctor, `/v1/responses` proxy, streaming pass-through, pre-first-byte failover, debug headers | | Implemented MVP | Local ocswitch config, provider/alias CLI, OpenCode sync, static doctor, `/v1/responses` proxy, streaming pass-through, pre-first-byte failover, debug headers |
| Partial / missing MVP edges | `doctor` validates generated preview more than on-disk synced state; OpenCode provider import only reads `options.apiKey`, not broader header-style auth | | Partial / missing MVP edges | `doctor` validates generated preview more than on-disk synced state; OpenCode provider import only reads `options.apiKey`, not broader header-style auth |
| Beyond MVP | `provider enable/disable`, minimal `/v1/models`, broader alias normalization, careful OpenCode config patching | | Beyond MVP | `provider enable/disable`, minimal `/v1/models`, broader alias normalization, careful OpenCode config patching |
@ -191,7 +191,7 @@ Fixed a panic in opencode sync when preserved model metadata contains slices, an
### Main Changes ### Main Changes
- Root cause: `internal/opencode/opencode.go` compared `interface{}` values directly inside `mapsEqualShallow`, which panicked when preserved `provider.olpx.models.<alias>` metadata included slices. - Root cause: `internal/opencode/opencode.go` compared `interface{}` values directly inside `mapsEqualShallow`, which panicked when preserved `provider.ocswitch.models.<alias>` metadata included slices.
- Fix: replaced the unsafe hand-rolled comparison with `reflect.DeepEqual` so existing model metadata with nested maps/slices can be compared safely during `sync`. - Fix: replaced the unsafe hand-rolled comparison with `reflect.DeepEqual` so existing model metadata with nested maps/slices can be compared safely during `sync`.
- Added regression tests in `internal/opencode/opencode_test.go` and `internal/cli/cli_test.go` covering preserved slice metadata and a real `opencode sync --target` no-op path. - Added regression tests in `internal/opencode/opencode_test.go` and `internal/cli/cli_test.go` covering preserved slice metadata and a real `opencode sync --target` no-op path.
- Verification: ran `rtk go test ./internal/opencode`, `rtk go test ./internal/cli ./internal/opencode`, and `rtk go test ./...` successfully. - Verification: ran `rtk go test ./internal/opencode`, `rtk go test ./internal/cli ./internal/opencode`, and `rtk go test ./...` successfully.
@ -214,3 +214,312 @@ Fixed a panic in opencode sync when preserved model metadata contains slices, an
### Next Steps ### Next Steps
- None - task complete - None - task complete
## Session 6: Rename CLI to ocswitch
**Date**: 2026-04-17
**Task**: Rename CLI to ocswitch
**Branch**: `master`
### Summary
Renamed the CLI and synced docs, examples, and Trellis history from olpx/opswitch to ocswitch while keeping the repository name opencode-provider-switch.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `cf3dcec` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 7: Deep code review for ocswitch
**Date**: 2026-04-17
**Task**: Deep code review for ocswitch
**Branch**: `master`
### Summary
Recorded the deep code review findings in Trellis, including 2 high-risk, 4 medium-risk, and 4 low-risk/improvement items with concrete remediation guidance.
### Main Changes
- Added `.trellis/tasks/04-17-deep-code-review-ocswitch/review.md` to capture the full deep review output and concrete remediation guidance.
- Recorded 2 high-risk items:
- concurrent config/OpenCode save paths are unsafe under concurrent writers
- proxy request body has no read timeout after headers
- Recorded 4 medium-risk items:
- streaming responses have no idle timeout
- retryable upstream failures are collapsed into a generic `502`
- `opencode sync --set-model` / `--set-small-model` can silently write invalid defaults
- fixed default API key remains unsafe when binding to non-loopback addresses
- Recorded 4 low-risk or improvement items:
- alias lifecycle lacks enable/disable recovery path
- provider import docs and implementation disagree on empty `apiKey`
- `opencode sync` side effects on JSONC comments and `$schema` need clearer docs
- header forwarding should better handle dynamic hop-by-hop headers and narrower forwarding rules
- Verification already completed during review:
- `go test ./...`
- `go test -race ./...`
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 8: Provider discovery and model ref hardening
**Date**: 2026-04-18
**Task**: Provider discovery and model ref hardening
**Branch**: `master`
### Summary
Completed provider model discovery hardening, provider/model parsing improvements, README/help sync, follow-up review fixes, and final test-name cleanup.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `09d0c1f` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 9: GUI desktop direction and architecture decision
**Date**: 2026-04-18
**Task**: forwarding-log-viewer-gui
**Branch**: `master`
### Summary
Recorded the GUI direction change from a local web-only surface to a desktop-shell follow-up, driven by explicit requirements for launch at login, native menu/tray integration, and native notifications.
### Main Changes
- Confirmed the existing Trellis task `04-17-forwarding-log-viewer-gui` remains historically correct as a completed local web log-viewer design task and should not be retrofitted in place.
- Recorded follow-up product guidance that desktop capabilities now justify a separate native-shell track rather than a browser-only UI.
- Captured the recommended follow-up stack as `Wails + React + TypeScript + Tailwind CSS + react-hook-form + zod`.
- Captured the preferred layering so existing Go logic is not rewritten into desktop-specific code:
- `internal/config` continues config IO and validation
- `internal/proxy` continues proxy runtime and failover
- `internal/opencode` continues OpenCode sync
- new `internal/app` application service layer is shared by CLI and GUI
- Wails desktop shell owns window, tray/menu, notifications, autostart, and frontend hosting only
- Recorded the frontend recommendation that React is a better fit than Vue for this project because the desktop control panel is form-heavy and aligns better with the chosen `react-hook-form + zod` stack.
### Git Commits
(No commits - Trellis documentation update only)
### Testing
- [OK] No code changes; updated Trellis task metadata and workspace journal only
### Status
[OK] **Completed**
### Next Steps
- Create a separate Trellis task for the desktop shell if implementation should proceed
## Session 10: Desktop shell skeleton implementation
**Date**: 2026-04-18
**Task**: Desktop shell skeleton implementation
**Branch**: `master`
### Summary
Implemented the first desktop-shell skeleton by introducing a shared internal/app layer, desktop preference persistence, CLI reuse of shared workflows, and a minimal desktop bootstrap with passing Go tests/builds.
### Main Changes
- Added `config.Desktop` to persist desktop-shell preferences (`launch_at_login`, `minimize_to_tray`, `notifications`) without coupling core runtime logic to a desktop framework.
- Added `internal/app/types.go` and `internal/app/service.go` as the shared application-service layer described in the PRD.
- Implemented shared DTOs and workflows for:
- overview
- provider listing
- doctor report generation
- OpenCode sync preview/apply plumbing
- proxy lifecycle management
- desktop preference read/write
- Refactored CLI reuse points so `doctor`, `opencode sync`, `provider list`, and `serve` now delegate through `internal/app` instead of keeping those orchestration paths CLI-only.
- Preserved CLI output behavior closely while centralizing the orchestration logic that future desktop bindings can call.
- Added `internal/desktop/` skeleton adapters (`app`, `bindings`, `tray`, `notify`, `autostart`) as placeholders for a future Wails/native shell integration, keeping desktop-only concerns outside `internal/app`.
- Added `cmd/ocswitch-desktop/main.go` as a minimal desktop bootstrap binary that exercises the shared bindings and confirms the skeleton is wired.
- Added `internal/app/service_test.go` to cover desktop preference persistence and proxy start/stop status flow.
- Verified the implementation with:
- `gofmt -w ...`
- `rtk go test ./...`
- `rtk go build ./...`
- Scope intentionally stayed at the PRD's recommended “skeleton wiring only” level: no Wails dependency, no React frontend, and no tray/autostart implementation yet.
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 11: Desktop control panel implementation
**Date**: 2026-04-18
**Task**: Desktop control panel implementation
**Branch**: `master`
### Summary
Extended the desktop-shell skeleton into a minimal usable local control panel by adding an embedded web UI, desktop HTTP bindings, alias/provider overview surfaces, proxy controls, desktop preference editing, and OpenCode sync/doctor actions while keeping the shared Go application layer intact.
### Main Changes
- Upgraded `cmd/ocswitch-desktop/main.go` from a skeleton print-only bootstrap into a runnable local desktop control panel entrypoint with `--config`, `--listen`, and `--no-open` flags.
- Added `internal/desktop/http.go` as a lightweight desktop shell runtime that:
- serves embedded frontend assets
- exposes JSON endpoints for overview, providers, aliases, proxy status/start/stop, desktop prefs, doctor, and OpenCode sync preview/apply
- opens the browser by default and shuts down cleanly on SIGINT/SIGTERM
- Added `web/` embedded assets (`web/assets.go`, `web/dist/index.html`, `web/dist/app.css`, `web/dist/app.js`) to provide a minimal control-panel UI without introducing Wails/WebKit runtime dependencies into the current repo.
- Expanded `internal/app`/`internal/desktop` bindings with alias DTOs and alias listing so the GUI can show both routable provider state and alias routing state.
- Added `internal/desktop/http_test.go` to verify the desktop control panel serves the app shell, returns overview JSON, and persists desktop preferences through the HTTP API.
- Verified with:
- `gofmt -w cmd/ocswitch-desktop/main.go internal/app/service.go internal/app/types.go internal/desktop/bindings.go internal/desktop/http.go internal/desktop/http_test.go web/assets.go`
- `rtk go test ./...`
- `rtk go build ./...`
- `go run ./cmd/ocswitch-desktop --no-open` followed by live HTTP checks against `/` and `/api/overview`
- Constraint note: the PRD still recommends Wails as the long-term native shell, but the current environment lacks `wails` CLI and desktop system dependencies, so this implementation deliberately ships a dependency-light local control panel first while keeping `internal/app` and `internal/desktop` ready for a future Wails swap-in.
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 12: Wails desktop shell integration
**Date**: 2026-04-18
**Task**: Wails desktop shell integration
**Branch**: `master`
### Summary
Migrated the desktop control panel onto a formal Wails + React/TypeScript structure while preserving a browser fallback shell, added real Linux XDG launch-at-login support, and verified both default and desktop_wails Go paths. Native desktop compilation now fails only on missing Linux system packages rather than repository structure issues.
### Main Changes
- Added Wails v2.12.0 to `go.mod` and introduced `wails.json` so the repository has a formal desktop-shell project structure.
- Split desktop entrypoints by build tag:
- `cmd/ocswitch-desktop/main_fallback.go` keeps the browser fallback shell for default builds.
- `cmd/ocswitch-desktop/main_wails.go` and root `main_wails.go` provide the real `desktop_wails` Wails entry path expected by the Wails CLI.
- Added `internal/desktop/wails.go` to centralize Wails startup configuration, bind the desktop app object, and serve `frontend/dist` assets from the same source used by the browser fallback shell.
- Expanded `internal/desktop/app.go` into a real desktop composition root with startup, shutdown, close-to-background, preference sync, metadata, and Wails-callable facade methods.
- Replaced the ad-hoc `web/` assets with a formal `frontend/` Vite + React + TypeScript app:
- `frontend/src/App.tsx` now owns the GUI.
- `frontend/src/api.ts` bridges both Wails-bound calls and fallback HTTP `/api/*` calls.
- `frontend/src/types.ts` mirrors the Go DTOs.
- `frontend/src/env.d.ts` declares the Wails bridge surface.
- `frontend/src/styles.css` carries forward the control-panel styling.
- Updated the fallback HTTP shell in `internal/desktop/http.go` to serve `frontendassets.DistFS()` and to align doctor/meta responses with the Wails bridge semantics.
- Implemented real Linux XDG launch-at-login handling in `internal/desktop/autostart.go` and added `internal/desktop/autostart_test.go` to cover write/remove behavior.
- Wired `MinimizeToTray` into close-to-background behavior through `internal/desktop/tray.go` using public Wails window lifecycle APIs only; intentionally did not depend on private Wails tray internals because the pinned Wails version does not expose a stable public tray API.
- Added tag-specific runtime helpers:
- `internal/desktop/runtime_wails.go` hides the Wails window.
- `internal/desktop/runtime_fallback.go` is a no-op for browser fallback mode.
- Removed the obsolete `web/` embedded assets directory after the frontend migration because all code now serves assets from `frontend/`.
- Verified with:
- `rtk npm install` (frontend dependencies)
- `rtk npm run build`
- `rtk go test ./...`
- `rtk go test -tags desktop_wails ./...`
- `wails build -tags desktop_wails -debug`
- Verification outcome:
- repository structure, bindings generation, frontend build, and tagged Go compilation all succeed
- `wails build` now reaches native Linux compilation and stops only at missing system packages (`pkg-config`, and likely GTK/WebKit dev packages)
- native tray menus and native notifications remain intentionally incomplete because the current pinned Wails release does not provide a stable public tray API and no notification backend has been integrated yet
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

207
README.md
View File

@ -1,13 +1,13 @@
# opencode-provider-switch (`olpx`) # opencode-provider-switch (`ocswitch`)
English README: `README_EN.md` English README: `README_EN.md`
`olpx` 是 OpenCode LocalProxy CLI给 OpenCode 使用的本地代理。 `ocswitch` 是 OpenCode Provider Switch CLI给 OpenCode 使用的本地代理。
它解决的问题很简单: 它解决的问题很简单:
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `olpx/gpt-5.4` - 你在 OpenCode 里只使用一个稳定的模型名,例如 `ocswitch/gpt-5.4`
- `olpx` 在本地把这个别名映射到多个上游 `provider/model` - `ocswitch` 在本地把这个别名映射到多个上游 `provider/model`
- 按你配置的顺序依次尝试上游 - 按你配置的顺序依次尝试上游
- 如果主上游在响应开始前失败,自动切到下一个上游 - 如果主上游在响应开始前失败,自动切到下一个上游
@ -16,17 +16,17 @@ English README: `README_EN.md`
## 适合什么场景 ## 适合什么场景
- 你有多个 OpenAI 兼容上游 - 你有多个 OpenAI 兼容上游
- 你不想在 OpenCode 里频繁切换 provider - 你不想在 OpenCode 里频繁切换 provider 或模型入口
- 你希望用一个固定别名承接多个备用上游 - 你希望用一个固定别名承接多个备用上游
- 你希望失败切换行为是确定的、可预期的 - 你希望失败切换行为是确定的、可预期的
## 当前能力 ## 当前能力
- 本地维护 `olpx` 配置文件:上游 provider、alias、监听地址 - 本地维护 `ocswitch` 配置文件:上游 provider、alias、监听地址
- 支持手动添加 provider - 支持手动添加 provider,并自动发现其 `/v1/models` 模型列表
- 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider - 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider
- 支持创建 alias并按顺序绑定多个上游 target - 支持创建 alias并按顺序绑定多个上游 target
- 支持把 alias 同步到 OpenCode 的 `provider.olpx.models` - 支持把 alias 同步到 OpenCode 的 `provider.ocswitch.models`
- 支持本地代理 `POST /v1/responses` - 支持本地代理 `POST /v1/responses`
- 支持流式透传 - 支持流式透传
- 支持首字节前失败切换 - 支持首字节前失败切换
@ -45,30 +45,29 @@ English README: `README_EN.md`
## 安装 ## 安装
```bash ```bash
go build -o olpx ./cmd/olpx go build -o ocswitch ./cmd/ocswitch
``` ```
如果你只想临时运行,也可以直接: 如果你只想临时运行,也可以直接:
```bash ```bash
go run ./cmd/olpx --help go run ./cmd/ocswitch --help
``` ```
## 5 分钟快速上手 ## 5 分钟快速上手
### 1. 添加上游 provider ### 1. 添加上游 provider
`olpx` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1` `ocswitch` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。默认会自动调用上游 `/v1/models` 拉取模型列表并缓存到本地配置,后续绑定 alias 时可用于校验模型名、减少手写 typo如果发现失败只会输出 warning不会阻止连接信息保存。如果某些 provider 不开放该接口,可以显式加 `--skip-models` 仅保存连接信息。
```bash ```bash
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx
olpx provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy
``` ```
如果某个上游还需要额外请求头,可以重复传 `--header` 如果某个上游还需要额外请求头,可以重复传 `--header`
```bash ```bash
olpx provider add \ ocswitch provider add \
--id relay \ --id relay \
--base-url https://example.com/v1 \ --base-url https://example.com/v1 \
--api-key sk-zzz \ --api-key sk-zzz \
@ -76,45 +75,50 @@ olpx provider add \
--header "X-Workspace=my-team" --header "X-Workspace=my-team"
``` ```
如果你之后想把这些额外 header 全部清空,可以显式传:
```bash
ocswitch provider add --id relay --base-url https://example.com/v1 --clear-headers --skip-models
```
查看当前 provider 查看当前 provider
```bash ```bash
olpx provider list ocswitch provider list
``` ```
### 2. 创建 alias并绑定多个上游 target ### 2. 创建 alias并绑定多个上游 target
下面这个例子表示:当你使用 `olpx/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4` 下面这个例子表示:当你使用 `ocswitch/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4`推荐在未传 `--provider` 时直接把上游 target 写成 `Provider/Model`;旧的 `--provider` + `--model` 写法仍然保留作为兼容兜底,而且 `model` 本身即使包含 `/` 也不会被强行改判。
```bash ```bash
olpx alias add --name gpt-5.4 --display-name "GPT 5.4" ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4
``` ```
查看当前 alias 查看当前 alias
```bash ```bash
olpx alias list ocswitch alias list
``` ```
注意: 注意:
- target 的顺序就是失败切换顺序 - target 的顺序就是失败切换顺序
- enabled alias 必须至少有一个可路由 target - enabled alias 必须至少有一个可路由 target
- `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias - `ocswitch alias bind` 在 alias 不存在时会自动创建一个 enabled alias
### 3. 先做一次静态检查 ### 3. 先做一次静态检查
```bash ```bash
olpx doctor ocswitch doctor
``` ```
`olpx doctor` 只做静态校验,不会真的请求上游,不会消耗额度。 `ocswitch doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
它会检查: 它会检查:
- 本地 `olpx` 配置能不能正常加载 - 本地 `ocswitch` 配置能不能正常加载
- alias 是否引用了不存在的 provider - alias 是否引用了不存在的 provider
- enabled alias 是否至少有一个可路由 target - enabled alias 是否至少有一个可路由 target
- 本地代理监听地址是否合理 - 本地代理监听地址是否合理
@ -123,56 +127,57 @@ olpx doctor
### 4. 把 alias 同步到 OpenCode ### 4. 把 alias 同步到 OpenCode
```bash ```bash
olpx opencode sync ocswitch opencode sync
``` ```
这个命令会做一件事:把当前可路由的 alias 列表同步进 OpenCode 的 `provider.olpx.models`。 这个命令会做一件事:把当前可路由的 alias 列表同步进 OpenCode 的 `provider.ocswitch.models`。
注意:如果目标文件原本是 JSONC`sync` 写回时会规范化成普通 JSON因此注释和尾逗号不会保留。
默认行为: 默认行为:
- 优先复用全局 OpenCode 配置文件:`opencode.jsonc` > `opencode.json` > `config.json` - 优先复用全局 OpenCode 配置文件:`opencode.jsonc` > `opencode.json` > `config.json`
- 如果都不存在,就创建 `~/.config/opencode/opencode.jsonc` - 如果都不存在,就创建 `~/.config/opencode/opencode.jsonc`
- 默认目标明确只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR` - 默认目标明确只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR`
- 只更新 `provider.olpx` - 只更新 `provider.ocswitch`
- 不会修改顶层 `model` - 不会修改顶层 `model`
- 不会修改顶层 `small_model` - 不会修改顶层 `small_model`
如果你希望顺手把默认模型也切到 `olpx`,需要显式指定: 如果你希望顺手把默认模型也切到 `ocswitch`,需要显式指定:
```bash ```bash
olpx opencode sync --set-model olpx/gpt-5.4 ocswitch opencode sync --set-model ocswitch/gpt-5.4
``` ```
如果你还有小模型 alias也可以这样 如果你还有小模型 alias也可以这样
```bash ```bash
olpx opencode sync \ ocswitch opencode sync \
--set-model olpx/gpt-5.4 \ --set-model ocswitch/gpt-5.4 \
--set-small-model olpx/gpt-5.4-mini --set-small-model ocswitch/gpt-5.4-mini
``` ```
先预览不写入: 先预览不写入:
```bash ```bash
olpx opencode sync --dry-run ocswitch opencode sync --dry-run
``` ```
写到指定 OpenCode 配置文件: 写到指定 OpenCode 配置文件:
```bash ```bash
olpx opencode sync --target /path/to/opencode.jsonc ocswitch opencode sync --target /path/to/opencode.jsonc
``` ```
### 5. 启动本地代理 ### 5. 启动本地代理
```bash ```bash
olpx serve ocswitch serve
``` ```
默认监听地址: 默认监听地址:
- `127.0.0.1:9982` - `127.0.0.1:9982`
- 本地 API Key`olpx-local` - 本地 API Key`ocswitch-local`
启动后,本地代理地址是: 启动后,本地代理地址是:
@ -182,68 +187,71 @@ http://127.0.0.1:9982/v1
### 6. 在 OpenCode 里使用 ### 6. 在 OpenCode 里使用
完成 `olpx opencode sync` 后,你应该能在 OpenCode 里看到 `olpx/<alias>`。 完成 `ocswitch opencode sync` 后,你应该能在 OpenCode 里看到 `ocswitch/<alias>`。
例如: 例如:
- `olpx/gpt-5.4` - `ocswitch/gpt-5.4`
如果你执行了: 如果你执行了:
```bash ```bash
olpx opencode sync --set-model olpx/gpt-5.4 ocswitch opencode sync --set-model ocswitch/gpt-5.4
``` ```
那么 OpenCode 默认模型也会直接切到这个 alias。 那么 OpenCode 默认模型也会直接切到这个 alias。
## 直接验证本地代理 ## 直接验证本地代理
如果你想先不走 OpenCode直接验证 `olpx` 是否能正常代理,可以自己发一个请求: 如果你想先不走 OpenCode直接验证 `ocswitch` 是否能正常代理,可以自己发一个请求:
```bash ```bash
curl -sN -X POST http://127.0.0.1:9982/v1/responses \ curl -sN -X POST http://127.0.0.1:9982/v1/responses \
-H "Authorization: Bearer olpx-local" \ -H "Authorization: Bearer ocswitch-local" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"model":"gpt-5.4","stream":true,"input":"hello"}' -d '{"model":"gpt-5.4","stream":true,"input":"hello"}'
``` ```
注意这里请求体里的 `model` 是 alias 本身,例如 `gpt-5.4`,不是 `olpx/gpt-5.4` 注意这里请求体里的 `model` 可以直接写 alias 本身,例如 `gpt-5.4`;也兼容 `ocswitch/gpt-5.4` 这种带前缀的写法
因为 `olpx/gpt-5.4` 是 OpenCode 侧的模型选择写法;真正发到本地 provider 的请求里,模型名会是 alias 自身。 因为 `ocswitch/gpt-5.4` 是 OpenCode 侧常见的模型选择写法;真正路由到本地时,工具会统一解析成 alias 自身。
## 从现有 OpenCode 配置导入 provider ## 从现有 OpenCode 配置导入 provider
如果你原来已经在 OpenCode 里配置过一些自定义 provider可以直接导入 如果你原来已经在 OpenCode 里配置过一些自定义 provider可以直接导入
```bash ```bash
olpx provider import-opencode ocswitch provider import-opencode
``` ```
或者指定导入源: 或者指定导入源:
```bash ```bash
olpx provider import-opencode --from ./examples/opencode.jsonc ocswitch provider import-opencode --from ./examples/opencode.jsonc
``` ```
支持范围只有这一类: 支持范围只有这一类:
- `npm: @ai-sdk/openai` - `npm: @ai-sdk/openai`
- 有 `options.baseURL` - 有 `options.baseURL`
- 有 `options.apiKey` - `options.apiKey` 可以为空;当前静态校验不会单独拦截空上游 API Key这类问题通常会在真实请求上游时暴露
注意: 注意:
- 这不是完整迁移工具 - 这不是完整迁移工具
- 默认导入源也只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR` - 默认导入源也只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR`
- 如果你要导入别的 OpenCode 配置文件,请显式传 `--from` - 如果你要导入别的 OpenCode 配置文件,请显式传 `--from`
- 当前只导入 provider 的基本连接信息 - 导入时仍要求 `baseURL` 满足 `/v1` 约束;不合法的 provider 会被跳过
- 如果你的旧配置依赖额外自定义 header需要导入后自己用 `olpx provider add --header ...` 补齐 - 如果源 OpenCode 配置已经声明 `models`,导入时会一并保留,但这类导入值默认只作为展示/迁移信息,不会像主动发现到的 catalog 那样做硬校验
- `olpx` 自己不会被反向导入 - 如果你修改了 provider 的连接信息,但本次 discovery 被跳过、失败或返回空列表,旧 catalog 会降级成“不可信元数据”:仍保留在配置里,但不会继续用于强校验
- 如果你的 provider 不开放 `/v1/models`,后续可用 `ocswitch provider add --skip-models ...` 仅保存连接信息
- `--overwrite` 会更新导入到的基本连接信息,但仍保留本地的 disabled 状态、额外 header以及仍然可信的 discovered catalog
- 如果你的旧配置依赖额外自定义 header首次导入后仍可能需要自己用 `ocswitch provider add --header ...` 补齐
- `ocswitch` 自己不会被反向导入
覆盖已存在的 provider 覆盖已存在的 provider
```bash ```bash
olpx provider import-opencode --overwrite ocswitch provider import-opencode --overwrite
``` ```
## 常用命令 ## 常用命令
@ -253,65 +261,73 @@ olpx provider import-opencode --overwrite
添加或更新 provider 添加或更新 provider
```bash ```bash
olpx provider add --id <id> --base-url <url-with-/v1> --api-key <key> ocswitch provider add --id <id> --base-url <url-with-/v1> --api-key <key>
ocswitch provider add --id <id> --base-url <url-with-/v1> --api-key ""
ocswitch provider add --id <id> --base-url <url-with-/v1> --clear-headers
ocswitch provider add --id <id> --base-url <url-with-/v1> --skip-models
``` ```
如果你需要把已保存的上游 API key 清空,显式传 `--api-key ""` 即可。
如果你需要把已保存的额外 header 清空,显式传 `--clear-headers` 即可。
查看 provider 查看 provider
```bash ```bash
olpx provider list ocswitch provider list
``` ```
禁用 provider 禁用 provider
```bash ```bash
olpx provider disable <id> ocswitch provider disable <id>
``` ```
重新启用 provider 重新启用 provider
```bash ```bash
olpx provider enable <id> ocswitch provider enable <id>
``` ```
删除 provider 删除 provider
```bash ```bash
olpx provider remove <id> ocswitch provider remove <id>
``` ```
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`olpx doctor` 会报错。 注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`ocswitch doctor` 会报错。
### alias ### alias
创建或更新 alias 创建或更新 alias
```bash ```bash
olpx alias add --name <alias> ocswitch alias add --name <alias>
``` ```
给 alias 追加一个 target 给 alias 追加一个 target
```bash ```bash
olpx alias bind --alias <alias> --provider <provider-id> --model <upstream-model> ocswitch alias bind --alias <alias> --model <provider-id>/<upstream-model>
ocswitch alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
``` ```
解绑 target 解绑 target
```bash ```bash
olpx alias unbind --alias <alias> --provider <provider-id> --model <upstream-model> ocswitch alias unbind --alias <alias> --model <provider-id>/<upstream-model>
ocswitch alias unbind --alias <alias> --provider <provider-id> --model <upstream-model>
``` ```
查看 alias 查看 alias
```bash ```bash
olpx alias list ocswitch alias list
``` ```
删除 alias 删除 alias
```bash ```bash
olpx alias remove <alias> ocswitch alias remove <alias>
``` ```
### 其他 ### 其他
@ -319,44 +335,46 @@ olpx alias remove <alias>
静态检查: 静态检查:
```bash ```bash
olpx doctor ocswitch doctor
``` ```
启动代理: 启动代理:
```bash ```bash
olpx serve ocswitch serve
``` ```
同步到 OpenCode 同步到 OpenCode
```bash ```bash
olpx opencode sync ocswitch opencode sync
``` ```
全局帮助: 全局帮助:
```bash ```bash
olpx --help ocswitch --help
olpx provider --help ocswitch provider --help
olpx alias --help ocswitch alias --help
olpx opencode sync --help ocswitch opencode sync --help
``` ```
## 配置文件说明 ## 配置文件说明
本地 `olpx` 配置文件默认路径: 本地 `ocswitch` 配置文件默认路径:
- 如果设置了 `OLPX_CONFIG`,优先使用它 - 如果设置了 `OCSWITCH_CONFIG`,优先使用它
- 否则使用 `$XDG_CONFIG_HOME/olpx/config.json` - 否则使用 `$XDG_CONFIG_HOME/ocswitch/config.json`
- 再否则使用 `~/.config/olpx/config.json` - 再否则使用 `~/.config/ocswitch/config.json`
也可以对每个命令显式指定: 也可以对每个命令显式指定:
```bash ```bash
olpx --config /path/to/config.json doctor ocswitch --config /path/to/config.json doctor
``` ```
命令级行为、默认值、写入范围与副作用,以对应命令的 `--help` 为准README 主要保留快速上手与背景说明。
一个最小配置示例: 一个最小配置示例:
```json ```json
@ -364,7 +382,7 @@ olpx --config /path/to/config.json doctor
"server": { "server": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 9982, "port": 9982,
"api_key": "olpx-local" "api_key": "ocswitch-local"
}, },
"providers": [ "providers": [
{ {
@ -405,7 +423,7 @@ olpx --config /path/to/config.json doctor
## 失败切换规则 ## 失败切换规则
`olpx` 的切换规则很保守,也很容易理解。 `ocswitch` 的切换规则很保守,也很容易理解。
会切换到下一个 target 的情况: 会切换到下一个 target 的情况:
@ -434,13 +452,13 @@ olpx --config /path/to/config.json doctor
## 调试响应头 ## 调试响应头
每次成功代理或透传上游错误时,响应里都会附带这些头: 当请求已经选定某个上游并开始向客户端返回该次尝试的结果时,响应里会附带这些头:
- `X-OLPX-Alias` - `X-OCSWITCH-Alias`
- `X-OLPX-Provider` - `X-OCSWITCH-Provider`
- `X-OLPX-Remote-Model` - `X-OCSWITCH-Remote-Model`
- `X-OLPX-Attempt` - `X-OCSWITCH-Attempt`
- `X-OLPX-Failover-Count` - `X-OCSWITCH-Failover-Count`
你可以用它们确认: 你可以用它们确认:
@ -452,18 +470,18 @@ olpx --config /path/to/config.json doctor
## 常见问题 ## 常见问题
### 为什么 `opencode models` 里看不到 `olpx/<alias>` ### 为什么 `opencode models` 里看不到 `ocswitch/<alias>`
先检查这几件事: 先检查这几件事:
1. 你是否执行过 `olpx opencode sync` 1. 你是否执行过 `ocswitch opencode sync`
2. 你的 alias 是否是 enabled 状态 2. 你的 alias 是否是 enabled 状态
3. alias 是否至少绑定了一个可路由 target 3. alias 是否至少绑定了一个可路由 target
4. alias 绑定的 provider 是否都被禁用了 4. alias 绑定的 provider 是否都被禁用了
5. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件 5. OpenCode 当前实际使用的配置文件,是否就是 `ocswitch opencode sync` 写入的那个文件
6. 执行一次 `olpx doctor`,看输出里的 `opencode config target` 6. 执行一次 `ocswitch doctor`,看输出里的 `opencode config target`
### 为什么 `olpx doctor` 报 alias 没有可用 target ### 为什么 `ocswitch doctor` 报 alias 没有可用 target
因为当前实现要求:只要 alias 是 enabled就必须至少有一个可路由的 target。 因为当前实现要求:只要 alias 是 enabled就必须至少有一个可路由的 target。
@ -482,14 +500,15 @@ olpx --config /path/to/config.json doctor
因为 provider 可能被多个 alias 复用。 因为 provider 可能被多个 alias 复用。
`olpx provider disable` 只会让路由层在 failover 时自动跳过这个 provider不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。 `ocswitch provider disable` 只会让路由层在 failover 时自动跳过这个 provider不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。
### 删除 provider 后为什么还有报错? ### 删除 provider 后为什么还有报错?
因为 alias 里的 target 还是旧引用。需要继续执行: 因为 alias 里的 target 还是旧引用。需要继续执行:
```bash ```bash
olpx alias unbind --alias <alias> --provider <provider-id> --model <model> ocswitch alias unbind --alias <alias> --model <provider-id>/<model>
ocswitch alias unbind --alias <alias> --provider <provider-id> --model <model>
``` ```
### 本地代理鉴权是什么? ### 本地代理鉴权是什么?
@ -497,15 +516,15 @@ olpx alias unbind --alias <alias> --provider <provider-id> --model <model>
默认是静态 key 默认是静态 key
```text ```text
olpx-local ocswitch-local
``` ```
OpenCode 在 `provider.olpx.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。 OpenCode 在 `provider.ocswitch.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
## 安全说明 ## 安全说明
- 默认只监听 `127.0.0.1` - 默认只监听 `127.0.0.1`
- 上游凭据保存在本地 `olpx` 配置文件中 - 上游凭据保存在本地 `ocswitch` 配置文件中
- 本项目当前没有做多用户或远程网络安全保证 - 本项目当前没有做多用户或远程网络安全保证
所以请把本地配置文件当成敏感文件处理。 所以请把本地配置文件当成敏感文件处理。

View File

@ -1,15 +1,15 @@
# opencode-provider-switch (`olpx`) # opencode-provider-switch (`ocswitch`)
A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one
stable model alias** routed to **multiple upstream providers** with stable model alias** routed to **multiple upstream providers** with
**deterministic failover**. **deterministic failover**.
- Expose one custom provider `olpx` to OpenCode. - Expose one custom provider `ocswitch` to OpenCode.
- Configure logical aliases (`olpx/gpt-5.4`, etc.). - Configure logical aliases (`ocswitch/gpt-5.4`, etc.).
- Each alias has an ordered list of upstream `provider/model` targets. - Each alias has an ordered list of upstream `provider/model` targets.
- Providers can be disabled without mutating alias target state. - Providers can be disabled without mutating alias target state.
- When the primary upstream returns `5xx`/`429`/connect error *before* any - When the primary upstream returns `5xx`/`429`/connect error *before* any
stream bytes are flushed, `olpx` transparently retries the next target. stream bytes are flushed, `ocswitch` transparently retries the next target.
- Once a stream has started, the upstream is locked for the rest of that - Once a stream has started, the upstream is locked for the rest of that
request — no mid-stream splicing. request — no mid-stream splicing.
@ -18,51 +18,57 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
## Install ## Install
```bash ```bash
go build -o olpx ./cmd/olpx go build -o ocswitch ./cmd/ocswitch
``` ```
## Quick start ## Quick start
```bash ```bash
# 1. add upstream providers # 1. add upstream providers (models are discovered from /v1/models by default; warnings do not block saving)
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-... ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
olpx provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-... ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-...
# 2. create alias and bind targets in priority order # 2. create alias and bind targets in priority order (preferred Provider/Model form)
olpx alias add --name gpt-5.4 ocswitch alias add --name gpt-5.4
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4
# 3. push alias exposure into OpenCode global config # 3. push alias exposure into OpenCode global config
olpx opencode sync ocswitch opencode sync
# optional: temporarily disable one provider without editing alias targets # optional: temporarily disable one provider without editing alias targets
olpx provider disable su8 ocswitch provider disable su8
# 4. run the proxy # 4. run the proxy
olpx serve ocswitch serve
``` ```
Inside OpenCode you can now pick `ocswitch/gpt-5.4`.
Inside OpenCode you can now pick `olpx/gpt-5.4`.
### Import providers from an existing OpenCode config ### Import providers from an existing OpenCode config
```bash ```bash
olpx provider import-opencode # reads global OpenCode config ocswitch provider import-opencode # reads global OpenCode config
olpx provider import-opencode --from ./examples/opencode.jsonc ocswitch provider import-opencode --from ./examples/opencode.jsonc
``` ```
The default import/sync target is the global user config only. It does not The default import/sync target is the global user config only. It does not
follow `OPENCODE_CONFIG_DIR`; use `--from` or `--target` when you want a follow `OPENCODE_CONFIG_DIR`; use `--from` or `--target` when you want a
different file. different file.
Only `@ai-sdk/openai` custom providers with a `baseURL` and `apiKey` are Only `@ai-sdk/openai` custom providers with a `baseURL` are imported. An empty
imported. Everything else is out of MVP scope. `apiKey` is allowed and kept as-is so you can complete credentials later.
Imported provider model lists are preserved when the source config already
declares `models`, and `provider add` will otherwise refresh them from
`/v1/models` by default. Imported lists are kept for migration context, while
hard typo validation only uses catalogs actively discovered from `/v1/models`.
If connection details change but discovery is skipped, fails, or returns an
empty list, any old catalog is retained only as untrusted metadata and no longer
used for strict validation.
### Doctor (static) ### Validate before serving
```bash ```bash
olpx doctor ocswitch doctor
``` ```
Runs structural checks only — never issues real upstream requests. Runs structural checks only — never issues real upstream requests.
@ -75,14 +81,14 @@ considered routable only when:
- the referenced provider exists - the referenced provider exists
- the referenced provider is not disabled - the referenced provider is not disabled
`olpx opencode sync` and `/v1/models` use the same routable-alias view, so `ocswitch opencode sync` and `/v1/models` use the same routable-alias view, so
OpenCode does not see aliases that the proxy would immediately reject. OpenCode does not see aliases that the proxy would immediately reject.
### Provider state ### Provider state
```bash ```bash
olpx provider disable <id> ocswitch provider disable <id>
olpx provider enable <id> ocswitch provider enable <id>
``` ```
Disabling a provider only removes it from routing/failover consideration. It Disabling a provider only removes it from routing/failover consideration. It
@ -91,28 +97,38 @@ interactions when the same provider is shared across multiple aliases.
## CLI reference ## CLI reference
- `olpx serve` — run the proxy For exact command behavior, defaults, write scope, and side effects, prefer the
- `olpx doctor` — validate config matching `--help` page. This README is the quick-start narrative, while CLI help
- `olpx provider {add,list,enable,disable,remove,import-opencode}` is the authoritative local execution contract.
- `olpx alias {add,list,bind,unbind,remove}`
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
Global flag: `--config PATH` (default `$XDG_CONFIG_HOME/olpx/config.json`). - `ocswitch serve` — run the proxy
- `ocswitch doctor` — validate config
- `ocswitch provider {add,list,enable,disable,remove,import-opencode}`
- `ocswitch alias {add,list,bind,unbind,remove}`
- `ocswitch opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
Preferred bind form: `ocswitch alias bind --alias <alias> --model <provider>/<model>` when `--provider` is omitted.
The legacy `--provider <id> --model <model>` form is still accepted as a fallback, including models whose names already contain `/`.
The same combined form also works for `alias unbind`.
To clear a previously saved upstream API key, pass `--api-key ""` explicitly on `provider add`.
To clear previously saved extra provider headers, pass `--clear-headers` on `provider add`.
Global flag: `--config PATH` (default `$OCSWITCH_CONFIG`, else `$XDG_CONFIG_HOME/ocswitch/config.json`, else `~/.config/ocswitch/config.json`).
## Debug headers ## Debug headers
Every proxied response includes: Responses include these debug headers once a concrete upstream attempt is being returned to the client:
- `X-OLPX-Alias` - `X-OCSWITCH-Alias`
- `X-OLPX-Provider` - `X-OCSWITCH-Provider`
- `X-OLPX-Remote-Model` - `X-OCSWITCH-Remote-Model`
- `X-OLPX-Attempt` - `X-OCSWITCH-Attempt`
- `X-OLPX-Failover-Count` - `X-OCSWITCH-Failover-Count`
## Scope ## Scope
Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing, Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing,
latency-based routing, full `/v1/models` provider discovery, full OpenCode latency-based routing, and full OpenCode config takeover.
config takeover.
See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md` See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md`
for the authoritative design notes. for the authoritative design notes.

View File

@ -0,0 +1,32 @@
//go:build !desktop_wails
package main
import (
"flag"
"fmt"
"os"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/desktop"
)
var version = "dev"
func main() {
configPath := flag.String("config", "", fmt.Sprintf("path to %s config.json (default: %s)", config.AppName, config.DefaultPath()))
listenAddr := flag.String("listen", "127.0.0.1:0", "listen address for the local desktop control panel")
noOpen := flag.Bool("no-open", false, "do not open the control panel in the default browser")
flag.Parse()
err := desktop.Run(desktop.RunOptions{
ConfigPath: *configPath,
Version: version,
ListenAddr: *listenAddr,
OpenBrowser: !*noOpen,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@ -0,0 +1,25 @@
//go:build desktop_wails
package main
import (
"flag"
"fmt"
"os"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/desktop"
)
var version = "dev"
func main() {
configPath := flag.String("config", "", fmt.Sprintf("path to %s config.json (default: %s)", config.AppName, config.DefaultPath()))
flag.Parse()
err := desktop.RunWails(*configPath, version)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@ -1,11 +1,11 @@
// Command olpx: local alias + failover proxy for OpenCode. // Command ocswitch: local alias + failover proxy for OpenCode.
package main package main
import ( import (
"fmt" "fmt"
"os" "os"
"github.com/anomalyco/opencode-provider-switch/internal/cli" "github.com/Apale7/opencode-provider-switch/internal/cli"
) )
// version is overridden at build time via -ldflags "-X main.version=...". // version is overridden at build time via -ldflags "-X main.version=...".

View File

@ -1,5 +1,5 @@
{ {
// Sanitized example for `olpx provider import-opencode --from ./examples/opencode.jsonc` // Sanitized example for `ocswitch provider import-opencode --from ./examples/opencode.jsonc`
"model": "su8/gpt-5.4", "model": "su8/gpt-5.4",
"small_model": "codex/GPT-5.4-mini", "small_model": "codex/GPT-5.4-mini",
"provider": { "provider": {

View File

@ -1,11 +1,11 @@
{ {
// provider.olpx.models entries may include OpenCode-only metadata. // provider.ocswitch.models entries may include OpenCode-only metadata.
// olpx sync preserves same-name model objects and only manages the alias set. // ocswitch sync preserves same-name model objects and only manages the alias set.
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"model": "olpx/gpt-5.4", "model": "ocswitch/gpt-5.4",
"small_model": "olpx/gpt-5.4-mini", "small_model": "ocswitch/gpt-5.4-mini",
"provider": { "provider": {
"olpx": { "ocswitch": {
"models": { "models": {
"gpt-5.4": { "gpt-5.4": {
"name": "gpt-5.4", "name": "gpt-5.4",
@ -50,10 +50,10 @@
"name": "gpt-5.4-mini" "name": "gpt-5.4-mini"
} }
}, },
"name": "OpenCode LocalProxy CLI", "name": "OpenCode Provider Switch CLI",
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"options": { "options": {
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"setCacheKey": true "setCacheKey": true
} }

13
frontend/assets.go Normal file
View File

@ -0,0 +1,13 @@
package frontend
import (
"embed"
"io/fs"
)
//go:embed dist/*
var assets embed.FS
func DistFS() (fs.FS, error) {
return fs.Sub(assets, "dist")
}

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ocswitch desktop</title>
<script>
window.__OCSWITCH_WAILS__ = typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined'
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1846
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "ocswitch-desktop-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.5"
}
}

1
frontend/package.json.md5 Executable file
View File

@ -0,0 +1 @@
2f0b51396a8b6c44ea214228c533bdbe

358
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,358 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import {
applySync,
getMeta,
getOverview,
listAliases,
listProviders,
previewSync,
runDoctor,
saveDesktopPrefs,
startProxy,
stopProxy,
} from './api'
import type {
AliasView,
DesktopPrefsView,
DoctorRunResult,
Overview,
ProviderView,
SyncInput,
SyncPreview,
SyncResult,
} from './types'
type MetaState = {
version: string
shell: string
url?: string
}
const emptyPrefs: DesktopPrefsView = {
launchAtLogin: false,
minimizeToTray: false,
notifications: false,
}
const emptySync: SyncInput = {
target: '',
setModel: '',
setSmallModel: '',
}
function pretty(value: unknown): string {
return JSON.stringify(value, null, 2)
}
export default function App() {
const [meta, setMeta] = useState<MetaState>({ version: 'dev', shell: 'loading' })
const [overview, setOverview] = useState<Overview | null>(null)
const [providers, setProviders] = useState<ProviderView[]>([])
const [aliases, setAliases] = useState<AliasView[]>([])
const [prefs, setPrefs] = useState<DesktopPrefsView>(emptyPrefs)
const [prefsStatus, setPrefsStatus] = useState('')
const [doctorStatus, setDoctorStatus] = useState('')
const [doctorResult, setDoctorResult] = useState<DoctorRunResult | null>(null)
const [syncStatus, setSyncStatus] = useState('')
const [syncInput, setSyncInput] = useState<SyncInput>(emptySync)
const [syncOutput, setSyncOutput] = useState<SyncPreview | SyncResult | string>('')
const [loading, setLoading] = useState(false)
const refreshAll = useCallback(async () => {
setLoading(true)
setPrefsStatus('Refreshing...')
try {
const [metaData, overviewData, providerData, aliasData] = await Promise.all([
getMeta(),
getOverview(),
listProviders(),
listAliases(),
])
setMeta(metaData)
setOverview(overviewData)
setProviders(providerData)
setAliases(aliasData)
setPrefs(overviewData.desktop)
setPrefsStatus('Fresh')
} catch (error) {
setPrefsStatus(error instanceof Error ? error.message : String(error))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refreshAll()
}, [refreshAll])
async function onSavePrefs(event: FormEvent) {
event.preventDefault()
setPrefsStatus('Saving...')
try {
const saved = await saveDesktopPrefs(prefs)
setPrefs(saved)
setPrefsStatus('Saved')
await refreshAll()
} catch (error) {
setPrefsStatus(error instanceof Error ? error.message : String(error))
}
}
async function onRunDoctor() {
setDoctorStatus('Running...')
try {
const result = await runDoctor()
setDoctorResult(result)
setDoctorStatus(result.error || 'Doctor OK')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setDoctorResult(null)
setDoctorStatus(message)
}
}
async function onStartProxy() {
try {
await startProxy()
await refreshAll()
} catch (error) {
setPrefsStatus(error instanceof Error ? error.message : String(error))
}
}
async function onStopProxy() {
try {
await stopProxy()
await refreshAll()
} catch (error) {
setPrefsStatus(error instanceof Error ? error.message : String(error))
}
}
async function onPreviewSync() {
setSyncStatus('Previewing...')
try {
const result = await previewSync(syncInput)
setSyncOutput(result)
setSyncStatus(result.wouldChange ? 'Preview shows changes' : 'Preview shows no changes')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setSyncOutput(message)
setSyncStatus(message)
}
}
async function onApplySync(event: FormEvent) {
event.preventDefault()
setSyncStatus('Applying...')
try {
const result = await applySync(syncInput)
setSyncOutput(result)
setSyncStatus(result.changed ? 'Sync applied' : 'Already up to date')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setSyncOutput(message)
setSyncStatus(message)
}
}
const stats = overview
? [
['Providers', String(overview.providerCount)],
['Aliases', String(overview.aliasCount)],
['Routable aliases', String(overview.availableAliases.length)],
['Proxy', overview.proxy.running ? 'Running' : 'Idle'],
]
: []
return (
<div className="shell">
<header className="hero">
<div>
<p className="eyebrow">ocswitch desktop</p>
<h1>Native control panel</h1>
<p className="subtle">
{meta.version || 'dev'} · {meta.shell} shell · {overview?.configPath || 'loading config'}
</p>
</div>
<div className="hero-actions">
<button type="button" className="primary" onClick={() => void refreshAll()} disabled={loading}>
Refresh
</button>
<button type="button" onClick={() => void onRunDoctor()}>
Run doctor
</button>
</div>
</header>
<main className="grid">
<section className="panel overview-panel">
<div className="panel-header">
<h2>Overview</h2>
<span className={`badge ${overview?.proxy.running ? 'live' : 'idle'}`}>
{overview?.proxy.running ? 'Proxy running' : 'Proxy idle'}
</span>
</div>
<div className="stats">
{stats.map(([label, value]) => (
<div className="stat" key={label}>
<span className="stat-label">{label}</span>
<span className="stat-value">{value}</span>
</div>
))}
</div>
<div className="toolbar">
<button type="button" className="primary" onClick={() => void onStartProxy()}>
Start proxy
</button>
<button type="button" onClick={() => void onStopProxy()}>
Stop proxy
</button>
</div>
<pre className="details">{overview ? pretty(overview) : 'Loading overview...'}</pre>
</section>
<section className="panel">
<div className="panel-header">
<h2>Desktop prefs</h2>
<span className="subtle">{prefsStatus}</span>
</div>
<form className="stack" onSubmit={(event) => void onSavePrefs(event)}>
<label className="checkbox-row">
<input
type="checkbox"
checked={prefs.launchAtLogin}
onChange={(event) => setPrefs((current) => ({ ...current, launchAtLogin: event.target.checked }))}
/>
<span>Launch at login</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={prefs.minimizeToTray}
onChange={(event) => setPrefs((current) => ({ ...current, minimizeToTray: event.target.checked }))}
/>
<span>Minimize to tray / close to background</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={prefs.notifications}
onChange={(event) => setPrefs((current) => ({ ...current, notifications: event.target.checked }))}
/>
<span>Native notifications</span>
</label>
<button type="submit" className="primary">
Save settings
</button>
</form>
</section>
<section className="panel">
<div className="panel-header">
<h2>OpenCode sync</h2>
<span className="subtle">{syncStatus}</span>
</div>
<form className="stack" onSubmit={(event) => void onApplySync(event)}>
<label>
<span>Target path</span>
<input
type="text"
value={syncInput.target || ''}
onChange={(event) => setSyncInput((current) => ({ ...current, target: event.target.value }))}
placeholder="Use default OpenCode config"
/>
</label>
<label>
<span>model</span>
<input
type="text"
value={syncInput.setModel || ''}
onChange={(event) => setSyncInput((current) => ({ ...current, setModel: event.target.value }))}
placeholder="ocswitch/&lt;alias&gt;"
/>
</label>
<label>
<span>small_model</span>
<input
type="text"
value={syncInput.setSmallModel || ''}
onChange={(event) => setSyncInput((current) => ({ ...current, setSmallModel: event.target.value }))}
placeholder="ocswitch/&lt;alias&gt;"
/>
</label>
<div className="toolbar">
<button type="button" onClick={() => void onPreviewSync()}>
Preview
</button>
<button type="submit" className="primary">
Apply sync
</button>
</div>
</form>
<pre className="details">{typeof syncOutput === 'string' ? syncOutput : pretty(syncOutput)}</pre>
</section>
<section className="panel">
<div className="panel-header">
<h2>Doctor report</h2>
<span className={`subtle ${doctorResult?.error ? 'tone-error' : 'tone-ok'}`}>{doctorStatus}</span>
</div>
<pre className="details">{doctorResult ? pretty(doctorResult) : 'Run doctor to inspect config and OpenCode wiring.'}</pre>
</section>
<section className="panel wide">
<div className="panel-header">
<h2>Providers</h2>
<span className="subtle">{providers.length} total</span>
</div>
<div className="list">
{providers.length === 0 ? <p className="subtle">No providers configured yet.</p> : null}
{providers.map((provider) => (
<article className="item-card" key={provider.id}>
<div>
<strong>{provider.name || provider.id}</strong>
<br />
<code>{provider.baseUrl}</code>
</div>
<div className="item-meta">
API key: {provider.apiKeyMasked || 'not set'}
<br />
Models: {provider.models?.join(', ') || 'none'}
<br />
Status: {provider.disabled ? 'disabled' : 'enabled'}
</div>
</article>
))}
</div>
</section>
<section className="panel wide">
<div className="panel-header">
<h2>Aliases</h2>
<span className="subtle">{aliases.length} total</span>
</div>
<div className="list">
{aliases.length === 0 ? <p className="subtle">No aliases configured yet.</p> : null}
{aliases.map((alias) => (
<article className="item-card" key={alias.alias}>
<div>
<strong>{alias.displayName || alias.alias}</strong>
<br />
<code>{alias.alias}</code>
</div>
<div className="item-meta">
Targets: {alias.availableTargetCount}/{alias.targetCount} routable
<br />
{alias.targets.map((target) => `${target.provider}/${target.model}${target.enabled ? '' : ' (disabled)'}`).join(', ') || 'No targets'}
<br />
Status: {alias.enabled ? 'enabled' : 'disabled'}
</div>
</article>
))}
</div>
</section>
</main>
</div>
)
}

99
frontend/src/api.ts Normal file
View File

@ -0,0 +1,99 @@
import type {
AliasView,
DesktopPrefsView,
DoctorRunResult,
MetaView,
Overview,
ProviderView,
ProxyStatusView,
SyncInput,
SyncPreview,
SyncResult,
} from './types'
type ApiEnvelope<T> = {
data: T
error?: string
}
function isWails(): boolean {
return Boolean(window.__OCSWITCH_WAILS__ && window.go?.main?.App)
}
async function http<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...init,
})
const payload = (await response.json()) as ApiEnvelope<T>
if (!response.ok) {
throw new Error(payload.error || 'request failed')
}
return payload.data
}
function bridge() {
const app = window.go?.main?.App
if (!app) {
throw new Error('Wails bridge unavailable')
}
return app
}
export async function getMeta(): Promise<MetaView> {
if (isWails()) {
const data = await bridge().Meta()
return { version: data.version || 'dev', shell: data.shell || 'wails' }
}
return http<MetaView>('/api/meta')
}
export function getOverview(): Promise<Overview> {
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
}
export function listProviders(): Promise<ProviderView[]> {
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
}
export function listAliases(): Promise<AliasView[]> {
return isWails() ? bridge().Aliases() : http<AliasView[]>('/api/aliases')
}
export function getDesktopPrefs(): Promise<DesktopPrefsView> {
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
}
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsView> {
return isWails()
? bridge().SavePrefs(input)
: http<DesktopPrefsView>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
}
export function runDoctor(): Promise<DoctorRunResult> {
return isWails() ? bridge().DoctorRun() : http<DoctorRunResult>('/api/doctor', { method: 'POST' })
}
export function getProxyStatus(): Promise<ProxyStatusView> {
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
}
export function startProxy(): Promise<ProxyStatusView> {
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
}
export function stopProxy(): Promise<ProxyStatusView> {
return isWails() ? bridge().StopProxy() : http<ProxyStatusView>('/api/proxy/stop', { method: 'POST' })
}
export function previewSync(input: SyncInput): Promise<SyncPreview> {
return isWails()
? bridge().PreviewSync(input)
: http<SyncPreview>('/api/opencode-sync/preview', { method: 'POST', body: JSON.stringify(input) })
}
export function applySync(input: SyncInput): Promise<SyncResult> {
return isWails()
? bridge().ApplySync(input)
: http<SyncResult>('/api/opencode-sync/apply', { method: 'POST', body: JSON.stringify(input) })
}

27
frontend/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
/// <reference types="vite/client" />
declare global {
interface Window {
__OCSWITCH_WAILS__?: boolean
go?: {
main?: {
App?: {
Meta: () => Promise<Record<string, string>>
Overview: () => Promise<import('./types').Overview>
Providers: () => Promise<import('./types').ProviderView[]>
Aliases: () => Promise<import('./types').AliasView[]>
DoctorRun: () => Promise<import('./types').DoctorRunResult>
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
StartProxy: () => Promise<import('./types').ProxyStatusView>
StopProxy: () => Promise<import('./types').ProxyStatusView>
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsView>
PreviewSync: (input: import('./types').SyncInput) => Promise<import('./types').SyncPreview>
ApplySync: (input: import('./types').SyncInput) => Promise<import('./types').SyncResult>
}
}
}
}
}
export {}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

232
frontend/src/styles.css Normal file
View File

@ -0,0 +1,232 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0b1020;
color: #eef2ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.18), transparent 28%),
linear-gradient(180deg, #0b1020 0%, #0f172a 100%);
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 12px;
background: rgba(15, 23, 42, 0.85);
color: #eef2ff;
padding: 0.75rem 1rem;
}
button.primary {
background: linear-gradient(135deg, #2563eb, #22c55e);
border: none;
}
button:disabled {
opacity: 0.65;
cursor: default;
}
input[type='text'] {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 10px;
background: rgba(15, 23, 42, 0.75);
color: #eef2ff;
padding: 0.75rem 0.9rem;
}
.shell {
max-width: 1280px;
margin: 0 auto;
padding: 1.25rem;
}
.hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.hero h1,
.panel h2 {
margin: 0;
}
.eyebrow {
margin: 0 0 0.35rem;
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #93c5fd;
}
.subtle {
color: #94a3b8;
}
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel {
background: rgba(15, 23, 42, 0.82);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 18px;
padding: 1rem;
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.35);
}
.wide,
.overview-panel {
grid-column: 1 / -1;
}
.panel-header,
.toolbar,
.hero-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.hero-actions,
.toolbar {
flex-wrap: wrap;
}
.stats {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 1rem 0;
}
.stat {
padding: 0.9rem;
border-radius: 14px;
background: rgba(30, 41, 59, 0.88);
}
.stat-label {
display: block;
font-size: 0.8rem;
color: #94a3b8;
}
.stat-value {
display: block;
margin-top: 0.25rem;
font-size: 1.45rem;
font-weight: 600;
}
.badge {
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.82rem;
}
.badge.live {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.badge.idle {
background: rgba(148, 163, 184, 0.14);
color: #cbd5e1;
}
.stack {
display: grid;
gap: 0.85rem;
}
.stack label {
display: grid;
gap: 0.4rem;
}
.checkbox-row {
display: flex !important;
align-items: center;
gap: 0.65rem;
}
.details {
margin: 0;
padding: 0.9rem;
border-radius: 14px;
background: rgba(2, 6, 23, 0.72);
color: #cbd5e1;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.list {
display: grid;
gap: 0.75rem;
margin-top: 0.9rem;
}
.item-card {
display: grid;
gap: 0.45rem;
padding: 0.9rem;
border-radius: 14px;
background: rgba(30, 41, 59, 0.82);
}
.item-card strong {
font-size: 1rem;
}
.item-card code {
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace;
}
.item-meta {
color: #cbd5e1;
}
.tone-error {
color: #fca5a5;
}
.tone-ok {
color: #86efac;
}
@media (max-width: 860px) {
.hero,
.panel-header,
.toolbar {
flex-direction: column;
align-items: stretch;
}
.grid,
.stats {
grid-template-columns: 1fr;
}
}

98
frontend/src/types.ts Normal file
View File

@ -0,0 +1,98 @@
export type DesktopPrefsView = {
launchAtLogin: boolean
minimizeToTray: boolean
notifications: boolean
}
export type ProxyStatusView = {
running: boolean
bindAddress: string
startedAt?: string
lastError?: string
}
export type Overview = {
configPath: string
providerCount: number
aliasCount: number
availableAliases: string[]
proxy: ProxyStatusView
desktop: DesktopPrefsView
}
export type ProviderView = {
id: string
name?: string
baseUrl: string
apiKeySet: boolean
apiKeyMasked?: string
headers?: Record<string, string>
models?: string[]
modelsSource?: string
disabled: boolean
}
export type AliasTargetView = {
provider: string
model: string
enabled: boolean
}
export type AliasView = {
alias: string
displayName?: string
enabled: boolean
targetCount: number
availableTargetCount: number
targets: AliasTargetView[]
}
export type DoctorIssue = {
message: string
}
export type DoctorReport = {
ok: boolean
issues: DoctorIssue[]
configPath: string
providerCount: number
aliasCount: number
proxyBindAddress: string
openCodeTargetPath: string
openCodeTargetFound: boolean
}
export type DoctorRunResult = {
report: DoctorReport
error?: string
}
export type SyncInput = {
target?: string
setModel?: string
setSmallModel?: string
dryRun?: boolean
}
export type SyncPreview = {
targetPath: string
aliasNames: string[]
setModel?: string
setSmallModel?: string
wouldChange: boolean
}
export type SyncResult = {
targetPath: string
aliasNames: string[]
changed: boolean
dryRun: boolean
setModel?: string
setSmallModel?: string
}
export type MetaView = {
version: string
shell: string
url?: string
}

21
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

49
frontend/wailsjs/go/desktop/App.d.ts vendored Executable file
View File

@ -0,0 +1,49 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {app} from '../models';
import {desktop} from '../models';
import {context} from '../models';
export function Aliases():Promise<Array<app.AliasView>>;
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
export function AutoStart():Promise<desktop.AutoStart>;
export function BeforeClose(arg1:context.Context):Promise<boolean>;
export function Bindings():Promise<desktop.Bindings>;
export function DesktopPrefs():Promise<app.DesktopPrefsView>;
export function DoctorRun():Promise<app.DoctorRunResult>;
export function Meta():Promise<Record<string, string>>;
export function Overview():Promise<app.Overview>;
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
export function Providers():Promise<Array<app.ProviderView>>;
export function ProxyStatus():Promise<app.ProxyStatusView>;
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
export function Service():Promise<app.Service>;
export function SetVersion(arg1:string):Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function StartProxy():Promise<app.ProxyStatusView>;
export function Startup(arg1:context.Context):Promise<void>;
export function StopProxy():Promise<app.ProxyStatusView>;
export function SyncDesktopPreferences(arg1:context.Context):Promise<void>;
export function Tray():Promise<desktop.Tray>;

View File

@ -0,0 +1,91 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Aliases() {
return window['go']['desktop']['App']['Aliases']();
}
export function ApplySync(arg1) {
return window['go']['desktop']['App']['ApplySync'](arg1);
}
export function AutoStart() {
return window['go']['desktop']['App']['AutoStart']();
}
export function BeforeClose(arg1) {
return window['go']['desktop']['App']['BeforeClose'](arg1);
}
export function Bindings() {
return window['go']['desktop']['App']['Bindings']();
}
export function DesktopPrefs() {
return window['go']['desktop']['App']['DesktopPrefs']();
}
export function DoctorRun() {
return window['go']['desktop']['App']['DoctorRun']();
}
export function Meta() {
return window['go']['desktop']['App']['Meta']();
}
export function Overview() {
return window['go']['desktop']['App']['Overview']();
}
export function PreviewSync(arg1) {
return window['go']['desktop']['App']['PreviewSync'](arg1);
}
export function Providers() {
return window['go']['desktop']['App']['Providers']();
}
export function ProxyStatus() {
return window['go']['desktop']['App']['ProxyStatus']();
}
export function SaveDesktopPrefs(arg1, arg2) {
return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2);
}
export function SavePrefs(arg1) {
return window['go']['desktop']['App']['SavePrefs'](arg1);
}
export function Service() {
return window['go']['desktop']['App']['Service']();
}
export function SetVersion(arg1) {
return window['go']['desktop']['App']['SetVersion'](arg1);
}
export function Shutdown(arg1) {
return window['go']['desktop']['App']['Shutdown'](arg1);
}
export function StartProxy() {
return window['go']['desktop']['App']['StartProxy']();
}
export function Startup(arg1) {
return window['go']['desktop']['App']['Startup'](arg1);
}
export function StopProxy() {
return window['go']['desktop']['App']['StopProxy']();
}
export function SyncDesktopPreferences(arg1) {
return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1);
}
export function Tray() {
return window['go']['desktop']['App']['Tray']();
}

400
frontend/wailsjs/go/models.ts Executable file
View File

@ -0,0 +1,400 @@
export namespace app {
export class AliasTargetView {
provider: string;
model: string;
enabled: boolean;
static createFrom(source: any = {}) {
return new AliasTargetView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.provider = source["provider"];
this.model = source["model"];
this.enabled = source["enabled"];
}
}
export class AliasView {
alias: string;
displayName?: string;
enabled: boolean;
targetCount: number;
availableTargetCount: number;
targets: AliasTargetView[];
static createFrom(source: any = {}) {
return new AliasView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.alias = source["alias"];
this.displayName = source["displayName"];
this.enabled = source["enabled"];
this.targetCount = source["targetCount"];
this.availableTargetCount = source["availableTargetCount"];
this.targets = this.convertValues(source["targets"], AliasTargetView);
}
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 DesktopPrefsInput {
launchAtLogin: boolean;
minimizeToTray: boolean;
notifications: boolean;
static createFrom(source: any = {}) {
return new DesktopPrefsInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.launchAtLogin = source["launchAtLogin"];
this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"];
}
}
export class DesktopPrefsView {
launchAtLogin: boolean;
minimizeToTray: boolean;
notifications: boolean;
static createFrom(source: any = {}) {
return new DesktopPrefsView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.launchAtLogin = source["launchAtLogin"];
this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"];
}
}
export class DoctorIssue {
message: string;
static createFrom(source: any = {}) {
return new DoctorIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.message = source["message"];
}
}
export class DoctorReport {
ok: boolean;
issues: DoctorIssue[];
configPath: string;
providerCount: number;
aliasCount: number;
proxyBindAddress: string;
openCodeTargetPath: string;
openCodeTargetFound: boolean;
static createFrom(source: any = {}) {
return new DoctorReport(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ok = source["ok"];
this.issues = this.convertValues(source["issues"], DoctorIssue);
this.configPath = source["configPath"];
this.providerCount = source["providerCount"];
this.aliasCount = source["aliasCount"];
this.proxyBindAddress = source["proxyBindAddress"];
this.openCodeTargetPath = source["openCodeTargetPath"];
this.openCodeTargetFound = source["openCodeTargetFound"];
}
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 DoctorRunResult {
report: DoctorReport;
error?: string;
static createFrom(source: any = {}) {
return new DoctorRunResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.report = this.convertValues(source["report"], DoctorReport);
this.error = source["error"];
}
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 ProxyStatusView {
running: boolean;
bindAddress: string;
// Go type: time
startedAt?: any;
lastError?: string;
static createFrom(source: any = {}) {
return new ProxyStatusView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.bindAddress = source["bindAddress"];
this.startedAt = this.convertValues(source["startedAt"], null);
this.lastError = source["lastError"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Overview {
configPath: string;
providerCount: number;
aliasCount: number;
availableAliases: string[];
proxy: ProxyStatusView;
desktop: DesktopPrefsView;
static createFrom(source: any = {}) {
return new Overview(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configPath = source["configPath"];
this.providerCount = source["providerCount"];
this.aliasCount = source["aliasCount"];
this.availableAliases = source["availableAliases"];
this.proxy = this.convertValues(source["proxy"], ProxyStatusView);
this.desktop = this.convertValues(source["desktop"], DesktopPrefsView);
}
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 ProviderView {
id: string;
name?: string;
baseUrl: string;
apiKeySet: boolean;
apiKeyMasked?: string;
headers?: Record<string, string>;
models?: string[];
modelsSource?: string;
disabled: boolean;
static createFrom(source: any = {}) {
return new ProviderView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.baseUrl = source["baseUrl"];
this.apiKeySet = source["apiKeySet"];
this.apiKeyMasked = source["apiKeyMasked"];
this.headers = source["headers"];
this.models = source["models"];
this.modelsSource = source["modelsSource"];
this.disabled = source["disabled"];
}
}
export class Service {
static createFrom(source: any = {}) {
return new Service(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class SyncInput {
target?: string;
setModel?: string;
setSmallModel?: string;
dryRun: boolean;
static createFrom(source: any = {}) {
return new SyncInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.target = source["target"];
this.setModel = source["setModel"];
this.setSmallModel = source["setSmallModel"];
this.dryRun = source["dryRun"];
}
}
export class SyncPreview {
targetPath: string;
aliasNames: string[];
setModel?: string;
setSmallModel?: string;
wouldChange: boolean;
static createFrom(source: any = {}) {
return new SyncPreview(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.targetPath = source["targetPath"];
this.aliasNames = source["aliasNames"];
this.setModel = source["setModel"];
this.setSmallModel = source["setSmallModel"];
this.wouldChange = source["wouldChange"];
}
}
export class SyncResult {
targetPath: string;
aliasNames: string[];
changed: boolean;
dryRun: boolean;
setModel?: string;
setSmallModel?: string;
static createFrom(source: any = {}) {
return new SyncResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.targetPath = source["targetPath"];
this.aliasNames = source["aliasNames"];
this.changed = source["changed"];
this.dryRun = source["dryRun"];
this.setModel = source["setModel"];
this.setSmallModel = source["setSmallModel"];
}
}
}
export namespace desktop {
export class AutoStart {
static createFrom(source: any = {}) {
return new AutoStart(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class Bindings {
static createFrom(source: any = {}) {
return new Bindings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class Tray {
static createFrom(source: any = {}) {
return new Tray(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
}

View File

@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

330
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file
View File

@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

31
go.mod
View File

@ -1,13 +1,42 @@
module github.com/anomalyco/opencode-provider-switch module github.com/Apale7/opencode-provider-switch
go 1.22.2 go 1.22.2
require ( require (
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/tidwall/jsonc v0.3.2 github.com/tidwall/jsonc v0.3.2
github.com/wailsapp/wails/v2 v2.12.0
) )
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
) )

82
go.sum
View File

@ -1,12 +1,94 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

447
internal/app/service.go Normal file
View File

@ -0,0 +1,447 @@
package app
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/opencode"
"github.com/Apale7/opencode-provider-switch/internal/proxy"
)
type Service struct {
configPath string
mu sync.Mutex
proxyCancel context.CancelFunc
proxyDone chan struct{}
proxyErr error
proxyStatus ProxyStatusView
}
func NewService(configPath string) *Service {
return &Service{configPath: strings.TrimSpace(configPath)}
}
func (s *Service) ConfigPath() string {
if s.configPath != "" {
return s.configPath
}
return config.DefaultPath()
}
func (s *Service) GetOverview(ctx context.Context) (Overview, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return Overview{}, err
}
aliases := cfg.AvailableAliasNames()
sort.Strings(aliases)
status := s.currentProxyStatus(proxyBindAddress(cfg))
return Overview{
ConfigPath: cfg.Path(),
ProviderCount: len(cfg.Providers),
AliasCount: len(cfg.Aliases),
AvailableAliases: aliases,
Proxy: status,
Desktop: desktopPrefsView(cfg.Desktop),
}, nil
}
func (s *Service) ListProviders(ctx context.Context) ([]ProviderView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
views := make([]ProviderView, 0, len(providers))
for _, provider := range providers {
views = append(views, providerView(provider))
}
return views, nil
}
func (s *Service) ListAliases(ctx context.Context) ([]AliasView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
aliases := append([]config.Alias(nil), cfg.Aliases...)
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
views := make([]AliasView, 0, len(aliases))
for _, alias := range aliases {
views = append(views, aliasView(cfg, alias))
}
return views, nil
}
func (s *Service) RunDoctor(ctx context.Context) (DoctorReport, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return DoctorReport{}, err
}
issues := cfg.Validate()
path, existed := opencode.ResolveGlobalConfigPath()
raw, err := opencode.Load(path)
if err != nil {
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
} else {
aliasNames := cfg.AvailableAliasNames()
baseURL := proxyBaseURL(cfg)
opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err))
}
}
report := DoctorReport{
OK: len(issues) == 0,
Issues: doctorIssues(issues),
ConfigPath: cfg.Path(),
ProviderCount: len(cfg.Providers),
AliasCount: len(cfg.Aliases),
ProxyBindAddress: proxyBindAddress(cfg),
OpenCodeTargetPath: path,
OpenCodeTargetFound: existed,
}
if report.OK {
return report, nil
}
return report, fmt.Errorf("%d config issue(s)", len(issues))
}
func (s *Service) PreviewOpenCodeSync(ctx context.Context, in SyncInput) (SyncPreview, error) {
prepared, err := s.prepareSync(ctx, in)
if err != nil {
return SyncPreview{}, err
}
return SyncPreview{
TargetPath: prepared.targetPath,
AliasNames: append([]string(nil), prepared.aliasNames...),
SetModel: in.SetModel,
SetSmallModel: in.SetSmallModel,
WouldChange: prepared.changed,
}, nil
}
func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResult, error) {
prepared, err := s.prepareSync(ctx, in)
if err != nil {
return SyncResult{}, err
}
result := SyncResult{
TargetPath: prepared.targetPath,
AliasNames: append([]string(nil), prepared.aliasNames...),
Changed: prepared.changed,
DryRun: in.DryRun,
SetModel: in.SetModel,
SetSmallModel: in.SetSmallModel,
}
if !prepared.changed || in.DryRun {
return result, nil
}
if err := opencode.Save(prepared.targetPath, prepared.raw); err != nil {
return SyncResult{}, err
}
return result, nil
}
func (s *Service) StartProxy(ctx context.Context) error {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return err
}
if errs := cfg.Validate(); len(errs) > 0 {
return errs[0]
}
bindAddress := proxyBindAddress(cfg)
started := false
s.mu.Lock()
if s.proxyCancel == nil {
runCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
s.proxyCancel = cancel
s.proxyDone = done
s.proxyErr = nil
s.proxyStatus = ProxyStatusView{
Running: true,
BindAddress: bindAddress,
StartedAt: time.Now(),
}
started = true
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
}
s.mu.Unlock()
if !started {
return nil
}
return nil
}
func (s *Service) StopProxy(ctx context.Context) error {
s.mu.Lock()
cancel := s.proxyCancel
done := s.proxyDone
s.mu.Unlock()
if cancel == nil || done == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) WaitProxy(ctx context.Context) error {
s.mu.Lock()
done := s.proxyDone
proxyErr := s.proxyErr
s.mu.Unlock()
if done == nil {
return proxyErr
}
select {
case <-done:
s.mu.Lock()
defer s.mu.Unlock()
return s.proxyErr
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxyStatusView{}, err
}
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
}
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return DesktopPrefsView{}, err
}
return desktopPrefsView(cfg.Desktop), nil
}
func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (DesktopPrefsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return DesktopPrefsView{}, err
}
cfg.Desktop = config.Desktop{
LaunchAtLogin: in.LaunchAtLogin,
MinimizeToTray: in.MinimizeToTray,
Notifications: in.Notifications,
}
if err := cfg.Save(); err != nil {
return DesktopPrefsView{}, err
}
return desktopPrefsView(cfg.Desktop), nil
}
type preparedSync struct {
targetPath string
aliasNames []string
raw opencode.Raw
changed bool
}
func (s *Service) prepareSync(ctx context.Context, in SyncInput) (preparedSync, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return preparedSync{}, err
}
if errs := cfg.Validate(); len(errs) > 0 {
return preparedSync{}, errs[0]
}
targetPath := strings.TrimSpace(in.Target)
if targetPath == "" {
resolved, _ := opencode.ResolveGlobalConfigPath()
targetPath = resolved
}
raw, err := opencode.Load(targetPath)
if err != nil {
return preparedSync{}, err
}
aliasNames := cfg.AvailableAliasNames()
sort.Strings(aliasNames)
if err := validateSyncedModelSelection(in.SetModel, aliasNames, "--set-model"); err != nil {
return preparedSync{}, err
}
if err := validateSyncedModelSelection(in.SetSmallModel, aliasNames, "--set-small-model"); err != nil {
return preparedSync{}, err
}
baseURL := proxyBaseURL(cfg)
changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if in.SetModel != "" && raw["model"] != in.SetModel {
raw["model"] = in.SetModel
changed = true
}
if in.SetSmallModel != "" && raw["small_model"] != in.SetSmallModel {
raw["small_model"] = in.SetSmallModel
changed = true
}
return preparedSync{targetPath: targetPath, aliasNames: aliasNames, raw: raw, changed: changed}, nil
}
func (s *Service) loadConfig() (*config.Config, error) {
return config.Load(s.configPath)
}
func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
s.mu.Lock()
defer s.mu.Unlock()
status := s.proxyStatus
if status.BindAddress == "" {
status.BindAddress = bindAddress
}
return status
}
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) {
err := proxy.New(cfg).ListenAndServe(runCtx)
s.mu.Lock()
defer s.mu.Unlock()
if s.proxyDone == done {
s.proxyCancel = nil
s.proxyDone = nil
}
s.proxyErr = err
status := s.proxyStatus
status.Running = false
status.BindAddress = bindAddress
if err != nil {
status.LastError = err.Error()
} else {
status.LastError = ""
}
s.proxyStatus = status
close(done)
}
func providerView(provider config.Provider) ProviderView {
return ProviderView{
ID: provider.ID,
Name: provider.Name,
BaseURL: provider.BaseURL,
APIKeySet: provider.APIKey != "",
APIKeyMasked: maskKey(provider.APIKey),
Headers: cloneHeaders(provider.Headers),
Models: append([]string(nil), provider.Models...),
ModelsSource: provider.ModelsSource,
Disabled: provider.Disabled,
}
}
func aliasView(cfg *config.Config, alias config.Alias) AliasView {
targets := make([]AliasTargetView, 0, len(alias.Targets))
for _, target := range alias.Targets {
targets = append(targets, AliasTargetView{
Provider: target.Provider,
Model: target.Model,
Enabled: target.Enabled,
})
}
return AliasView{
Alias: alias.Alias,
DisplayName: alias.DisplayName,
Enabled: alias.Enabled,
TargetCount: len(alias.Targets),
AvailableTargetCount: len(cfg.AvailableTargets(alias)),
Targets: targets,
}
}
func doctorIssues(errs []error) []DoctorIssue {
issues := make([]DoctorIssue, 0, len(errs))
for _, err := range errs {
issues = append(issues, DoctorIssue{Message: err.Error()})
}
return issues
}
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
return DesktopPrefsView{
LaunchAtLogin: prefs.LaunchAtLogin,
MinimizeToTray: prefs.MinimizeToTray,
Notifications: prefs.Notifications,
}
}
func validateSyncedModelSelection(value string, aliases []string, flagName string) error {
if value == "" {
return nil
}
const prefix = "ocswitch/"
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
}
alias := strings.TrimPrefix(value, prefix)
if alias == "" {
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
}
for _, name := range aliases {
if name == alias {
return nil
}
}
if len(aliases) == 0 {
return fmt.Errorf("%s requires at least one routable alias; run ocswitch alias list or doctor first", flagName)
}
choices := make([]string, 0, len(aliases))
for _, name := range aliases {
choices = append(choices, prefix+name)
}
return fmt.Errorf("%s %q is not a routable alias; available: %s", flagName, value, strings.Join(choices, ", "))
}
func proxyBindAddress(cfg *config.Config) string {
return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
}
func proxyBaseURL(cfg *config.Config) string {
return fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
}
func cloneHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func maskKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 8 {
return "***"
}
return k[:4] + "…" + k[len(k)-4:]
}

View File

@ -0,0 +1,120 @@
package app
import (
"context"
"net"
"net/http"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
LaunchAtLogin: true,
MinimizeToTray: true,
Notifications: true,
})
if err != nil {
t.Fatalf("SaveDesktopPrefs() error = %v", err)
}
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications {
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications {
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
}
}
func TestStartStopProxyUpdatesStatus(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
cfgPathPort := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Port = cfgPathPort
cfg.Server.Host = "127.0.0.1"
cfg.Server.APIKey = config.DefaultLocalAPIKey
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)
}
t.Cleanup(func() {
_ = svc.StopProxy(context.Background())
})
status, err := svc.GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if !status.Running {
t.Fatalf("status.Running = false, want true")
}
assertEventually(t, func() bool {
resp, err := http.Get("http://127.0.0.1:" + itoa(cfgPathPort) + "/healthz")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
})
if err := svc.StopProxy(context.Background()); err != nil {
t.Fatalf("StopProxy() error = %v", err)
}
status, err = svc.GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() after stop error = %v", err)
}
if status.Running {
t.Fatalf("status.Running = true, want false")
}
}
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
}
func itoa(v int) string {
return strconv.Itoa(v)
}

102
internal/app/types.go Normal file
View File

@ -0,0 +1,102 @@
package app
import "time"
type Overview struct {
ConfigPath string `json:"configPath"`
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
AvailableAliases []string `json:"availableAliases"`
Proxy ProxyStatusView `json:"proxy"`
Desktop DesktopPrefsView `json:"desktop"`
}
type ProxyStatusView struct {
Running bool `json:"running"`
BindAddress string `json:"bindAddress"`
StartedAt time.Time `json:"startedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type ProviderView struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
BaseURL string `json:"baseUrl"`
APIKeySet bool `json:"apiKeySet"`
APIKeyMasked string `json:"apiKeyMasked,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Models []string `json:"models,omitempty"`
ModelsSource string `json:"modelsSource,omitempty"`
Disabled bool `json:"disabled"`
}
type AliasTargetView struct {
Provider string `json:"provider"`
Model string `json:"model"`
Enabled bool `json:"enabled"`
}
type AliasView struct {
Alias string `json:"alias"`
DisplayName string `json:"displayName,omitempty"`
Enabled bool `json:"enabled"`
TargetCount int `json:"targetCount"`
AvailableTargetCount int `json:"availableTargetCount"`
Targets []AliasTargetView `json:"targets"`
}
type DoctorIssue struct {
Message string `json:"message"`
}
type DoctorReport struct {
OK bool `json:"ok"`
Issues []DoctorIssue `json:"issues"`
ConfigPath string `json:"configPath"`
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
ProxyBindAddress string `json:"proxyBindAddress"`
OpenCodeTargetPath string `json:"openCodeTargetPath"`
OpenCodeTargetFound bool `json:"openCodeTargetFound"`
}
type DoctorRunResult struct {
Report DoctorReport `json:"report"`
Error string `json:"error,omitempty"`
}
type SyncInput struct {
Target string `json:"target,omitempty"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
DryRun bool `json:"dryRun"`
}
type SyncPreview struct {
TargetPath string `json:"targetPath"`
AliasNames []string `json:"aliasNames"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
WouldChange bool `json:"wouldChange"`
}
type SyncResult struct {
TargetPath string `json:"targetPath"`
AliasNames []string `json:"aliasNames"`
Changed bool `json:"changed"`
DryRun bool `json:"dryRun"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
}
type DesktopPrefsView struct {
LaunchAtLogin bool `json:"launchAtLogin"`
MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"`
}
type DesktopPrefsInput struct {
LaunchAtLogin bool `json:"launchAtLogin"`
MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"`
}

View File

@ -7,13 +7,26 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
func newAliasCmd() *cobra.Command { func newAliasCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "alias", Use: "alias",
Short: "Manage logical aliases routed by olpx", Short: "Manage logical aliases routed by ocswitch",
Long: `Alias commands manage the user-facing model names that OpenCode sees as
ocswitch/<alias>.
Each alias contains an ordered target chain of provider/model pairs. Target
order is operational: ocswitch tries targets in order and only fails over before any
response bytes are sent downstream.
Common workflow: create an alias, bind primary and fallback targets, inspect the
result with alias list, then run doctor and opencode sync.`,
Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4
ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4
ocswitch alias list`,
} }
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd()) c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
return c return c
@ -25,6 +38,18 @@ func newAliasAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add", Use: "add",
Short: "Create or update an alias (without targets)", Short: "Create or update an alias (without targets)",
Long: `alias add creates or updates alias metadata in local ocswitch config.
It writes the alias record itself, but it does not add or validate targets.
Enabled aliases still need at least one routable target before doctor and
opencode sync will treat them as usable.
When updating an existing alias, omitted display-name preserves the current
value and existing targets stay attached. Typical next step: add targets with
ocswitch alias bind.`,
Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
ocswitch alias add --name gpt-5.4-mini --disabled
ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4 Reasoning"`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if name == "" { if name == "" {
return fmt.Errorf("--name is required") return fmt.Errorf("--name is required")
@ -53,7 +78,7 @@ func newAliasAddCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as olpx/<name> in OpenCode (required)") cmd.Flags().StringVar(&name, "name", "", "alias name exposed as ocswitch/<name> in OpenCode (required)")
cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name") cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name")
cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state") cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state")
return cmd return cmd
@ -63,6 +88,16 @@ func newAliasListCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List aliases and their target chains", Short: "List aliases and their target chains",
Long: `alias list prints aliases from local ocswitch config together with their target
chains.
Output shows alias enabled state, target order, target enabled markers, and a
note when a referenced provider is missing or disabled. This is the easiest way
to verify failover order before running doctor or opencode sync.
This command does not modify config and does not contact upstream providers.`,
Example: ` ocswitch alias list
ocswitch --config /path/to/config.json alias list`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -107,19 +142,46 @@ func newAliasBindCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "bind", Use: "bind",
Short: "Append a target (provider/model) to an alias in failover order", Short: "Append a target (provider/model) to an alias in failover order",
Long: `alias bind appends one provider/model target to an alias's ordered failover
chain in local ocswitch config.
The provider must already exist. If the alias does not exist yet, this command
auto-creates an enabled alias for convenience. You can pass the target either as
--provider <id> --model <name> or in the more natural combined form --model
<provider>/<model> when --provider is omitted; the combined form is recommended
and the explicit --provider flag remains as fallback compatibility. If the
provider has a stored model catalog discovered from /v1/models, bind validates
the model name against that discovered list.
Order matters: the first bound target is tried first, the second is fallback,
and so on. Typical next step: inspect with alias list, then run doctor.`,
Example: ` ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4
ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4
ocswitch alias bind --alias gpt-5.4 --provider relay --model gpt-5.4 --disabled`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" { if alias == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required") return fmt.Errorf("--alias and --model are required")
}
combinedProvider, combinedModel, combined := parseProviderModelRef(model)
if provider == "" {
if !combined {
return fmt.Errorf("--model must use <provider>/<model> when --provider is omitted")
}
provider = combinedProvider
model = combinedModel
} }
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
return err return err
} }
if cfg.FindProvider(provider) == nil { p := cfg.FindProvider(provider)
if p == nil {
return fmt.Errorf("provider %q does not exist; add it first", provider) return fmt.Errorf("provider %q does not exist; add it first", provider)
} }
if err := validateProviderModelKnown(provider, p.Models, p.ModelsSource, model); err != nil {
return err
}
if cfg.FindAlias(alias) == nil { if cfg.FindAlias(alias) == nil {
// auto-create enabled alias for ergonomics
cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true}) cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true})
} }
if err := cfg.AddTarget(alias, config.Target{Provider: provider, Model: model, Enabled: !disabled}); err != nil { if err := cfg.AddTarget(alias, config.Target{Provider: provider, Model: model, Enabled: !disabled}); err != nil {
@ -133,20 +195,43 @@ func newAliasBindCmd() *cobra.Command {
}, },
} }
cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)") cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)")
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)") cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (fallback; prefer --model provider/model)")
cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)") cmd.Flags().StringVar(&model, "model", "", "upstream target model, or provider/model when --provider is omitted (required)")
cmd.Flags().BoolVar(&disabled, "disabled", false, "add target in disabled state") cmd.Flags().BoolVar(&disabled, "disabled", false, "add target in disabled state")
return cmd return cmd
} }
func newAliasUnbindCmd() *cobra.Command { func newAliasUnbindCmd() *cobra.Command {
var alias, provider, model string var alias, provider, model string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "unbind", Use: "unbind",
Short: "Remove a target from an alias", Short: "Remove a target from an alias",
Long: `alias unbind removes one concrete provider/model target tuple from an alias in
local ocswitch config.
It does not delete the alias itself. Removing a target can leave the alias with
no routable targets, which doctor and opencode sync will then treat as invalid
or unavailable.
You can identify the target either as --provider <id> --model <name> or in the
recommended combined form --model <provider>/<model> when --provider is omitted.
The explicit --provider flag remains available as a compatibility fallback.
Typical next step: run alias list or doctor to confirm the remaining target
chain.`,
Example: ` ocswitch alias unbind --alias gpt-5.4 --model codex/GPT-5.4
ocswitch alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4
ocswitch doctor`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" { if alias == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required") return fmt.Errorf("--alias and --model are required")
}
combinedProvider, combinedModel, combined := parseProviderModelRef(model)
if provider == "" {
if !combined {
return fmt.Errorf("--model must use <provider>/<model> when --provider is omitted")
}
provider = combinedProvider
model = combinedModel
} }
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -163,8 +248,8 @@ func newAliasUnbindCmd() *cobra.Command {
}, },
} }
cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)") cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)")
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)") cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (fallback; prefer --model provider/model)")
cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)") cmd.Flags().StringVar(&model, "model", "", "upstream target model, or provider/model when --provider is omitted (required)")
return cmd return cmd
} }
@ -173,6 +258,16 @@ func newAliasRemoveCmd() *cobra.Command {
Use: "remove <alias>", Use: "remove <alias>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: "Delete an alias entirely", Short: "Delete an alias entirely",
Long: `alias remove deletes one alias and all of its target bindings from local ocswitch
config.
Future opencode sync runs will stop exposing that alias in provider.ocswitch.models.
This command does not directly clear top-level model selections that may still
reference the old alias in OpenCode config.
Typical next step: run ocswitch opencode sync if OpenCode exposure should be updated.`,
Example: ` ocswitch alias remove gpt-5.4
ocswitch opencode sync`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {

File diff suppressed because it is too large Load Diff

View File

@ -4,57 +4,51 @@ import (
"fmt" "fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
) )
func newDoctorCmd() *cobra.Command { func newDoctorCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "doctor", Use: "doctor",
Short: "Validate olpx config (static checks, no upstream requests)", Short: "Validate ocswitch config (static checks, no upstream requests)",
Long: `doctor performs static validation for local ocswitch config and the expected
OpenCode sync result.
It loads local ocswitch config, checks alias/provider consistency, resolves the
default OpenCode sync target, and validates what provider.ocswitch would look like
there. It does not send any real requests to upstream providers and does not
consume model quota.
Run doctor before opencode sync or serve whenever you changed providers,
aliases, or local server settings.`,
Example: ` ocswitch doctor
ocswitch --config /path/to/config.json doctor`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() report, err := appService().RunDoctor(cmd.Context())
if err != nil { if err != nil {
for _, issue := range report.Issues {
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", issue.Message)
}
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
marker := "(will be created)"
if report.OpenCodeTargetFound {
marker = "(exists)"
}
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress)
return err return err
} }
issues := cfg.Validate() fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", report.ConfigPath)
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
path, existed := opencode.ResolveGlobalConfigPath() fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
raw, err := opencode.Load(path)
if err != nil {
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
} else {
aliasNames := cfg.AvailableAliasNames()
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.olpx invalid: %w", err))
}
}
ok := len(issues) == 0
if ok {
fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", cfg.Path())
} else {
fmt.Fprintf(cmd.OutOrStdout(), "✗ config has %d issue(s):\n", len(issues))
for _, e := range issues {
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", e)
}
}
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", len(cfg.Providers))
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", len(cfg.Aliases))
// Preview resolved opencode config target
marker := "(will be created)" marker := "(will be created)"
if existed { if report.OpenCodeTargetFound {
marker = "(exists)" marker = "(exists)"
} }
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker) fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.olpx preview: valid=%v\n", ok) fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
if !ok {
return fmt.Errorf("%d config issue(s)", len(issues))
}
return nil return nil
}, },
} }

View File

@ -5,13 +5,24 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/opencode" "github.com/Apale7/opencode-provider-switch/internal/app"
) )
func newOpencodeCmd() *cobra.Command { func newOpencodeCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "opencode", Use: "opencode",
Short: "OpenCode integration commands", Short: "OpenCode integration commands",
Long: `OpenCode commands manage the narrow integration boundary between ocswitch and
OpenCode config.
These commands do not attempt full OpenCode config takeover. They are limited to
the provider.ocswitch sync path and optional top-level model fields when you opt in
explicitly.
Common workflow: validate with doctor first, inspect sync help, then run
opencode sync.`,
Example: ` ocswitch opencode sync --dry-run
ocswitch opencode sync --set-model ocswitch/gpt-5.4`,
} }
c.AddCommand(newOpencodeSyncCmd()) c.AddCommand(newOpencodeSyncCmd())
return c return c
@ -24,60 +35,50 @@ func newOpencodeSyncCmd() *cobra.Command {
var dryRun bool var dryRun bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "sync", Use: "sync",
Short: "Update provider.olpx in the global OpenCode config to match current aliases", Short: "Update provider.ocswitch in the global OpenCode config to match current aliases",
Long: `olpx opencode sync writes provider.olpx into the target OpenCode config. Long: `ocswitch opencode sync writes provider.ocswitch into the target OpenCode config.
By default it targets the global user config (~/.config/opencode), picking the By default it targets the global user config (~/.config/opencode), picking the
existing file in precedence order opencode.jsonc > opencode.json > config.json, existing file in precedence order opencode.jsonc > opencode.json > config.json,
or creating opencode.jsonc if none exists. It does NOT touch the top-level or creating opencode.jsonc if none exists. It does NOT touch the top-level
"model" or "small_model" unless --set-model / --set-small-model are given.`, "model" or "small_model" unless --set-model / --set-small-model are given.
If the target file is JSONC, sync rewrites it as normalized plain JSON and any
comments/trailing commas are lost. Existing provider.ocswitch model metadata is
preserved when alias names stay the same, but this command is still a writing
operation rather than a comment-preserving patcher.
The default target scope is only the global user config path; it does not follow
OPENCODE_CONFIG_DIR unless you pass --target yourself. The command writes alias
exposure into provider.ocswitch.models using only aliases that are currently
routable.
Use --dry-run to preview the resolved target file without writing it. Typical
workflow: run ocswitch doctor first, then sync, then start or restart ocswitch serve if
needed.`,
Example: ` ocswitch opencode sync
ocswitch opencode sync --dry-run
ocswitch opencode sync --set-model ocswitch/gpt-5.4
ocswitch opencode sync --set-model ocswitch/gpt-5.4 --set-small-model ocswitch/gpt-5.4-mini
ocswitch opencode sync --target /path/to/opencode.jsonc`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() result, err := appService().ApplyOpenCodeSync(cmd.Context(), app.SyncInput{
Target: target,
SetModel: setModel,
SetSmallModel: setSmallModel,
DryRun: dryRun,
})
if err != nil { if err != nil {
return err return err
} }
if errs := cfg.Validate(); len(errs) > 0 { if !result.Changed {
for _, e := range errs { fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", result.TargetPath)
cmd.PrintErrln("config error:", e)
}
return errs[0]
}
path := target
if path == "" {
p, _ := opencode.ResolveGlobalConfigPath()
path = p
}
raw, err := opencode.Load(path)
if err != nil {
return err
}
aliasNames := cfg.AvailableAliasNames()
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if setModel != "" {
if raw["model"] != setModel {
raw["model"] = setModel
changed = true
}
}
if setSmallModel != "" {
if raw["small_model"] != setSmallModel {
raw["small_model"] = setSmallModel
changed = true
}
}
if !changed {
fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", path)
return nil return nil
} }
if dryRun { if result.DryRun {
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", path) fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", result.TargetPath)
return nil return nil
} }
if err := opencode.Save(path, raw); err != nil { fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", result.TargetPath, len(result.AliasNames))
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.olpx into %s (%d alias(es))\n", path, len(aliasNames))
if setModel != "" { if setModel != "" {
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel) fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
} }

View File

@ -0,0 +1,33 @@
package cli
import (
"fmt"
"sort"
"strings"
)
func validateSyncedModelSelection(value string, aliases []string, flagName string) error {
const prefix = "ocswitch/"
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
}
alias := strings.TrimPrefix(value, prefix)
if alias == "" {
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
}
for _, name := range aliases {
if name == alias {
return nil
}
}
sorted := append([]string(nil), aliases...)
sort.Strings(sorted)
if len(sorted) == 0 {
return fmt.Errorf("%s requires at least one routable alias; run ocswitch alias list or doctor first", flagName)
}
choices := make([]string, 0, len(sorted))
for _, name := range sorted {
choices = append(choices, prefix+name)
}
return fmt.Errorf("%s %q is not a routable alias; available: %s", flagName, value, strings.Join(choices, ", "))
}

View File

@ -2,19 +2,32 @@ package cli
import ( import (
"fmt" "fmt"
"sort" "os"
"reflect"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/anomalyco/opencode-provider-switch/internal/opencode" "github.com/Apale7/opencode-provider-switch/internal/opencode"
) )
func newProviderCmd() *cobra.Command { func newProviderCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "provider", Use: "provider",
Short: "Manage upstream providers", Short: "Manage upstream providers",
Long: `Provider commands manage upstream OpenAI-compatible endpoints stored in the
local ocswitch config file.
Providers are separate from aliases: a provider defines connection details such
as base URL, API key, and extra headers, while aliases decide failover order by
binding one or more provider/model targets.
Common workflow: add or import providers first, inspect them with provider list,
then bind them to aliases with ocswitch alias bind.`,
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
ocswitch provider import-opencode
ocswitch provider list`,
} }
c.AddCommand( c.AddCommand(
newProviderAddCmd(), newProviderAddCmd(),
@ -30,10 +43,38 @@ func newProviderCmd() *cobra.Command {
func newProviderAddCmd() *cobra.Command { func newProviderAddCmd() *cobra.Command {
var id, name, baseURL, apiKey string var id, name, baseURL, apiKey string
var headers []string var headers []string
var clearHeaders bool
var disabled bool var disabled bool
var skipModels bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add", Use: "add",
Short: "Add or update an upstream provider", Short: "Add or update an upstream provider",
Long: `provider add creates or updates one upstream provider entry in local ocswitch
config.
It writes only the ocswitch config file. --base-url must point at an
OpenAI-compatible /v1 root. By default the command also calls the upstream
/v1/models endpoint with the supplied credentials and stores the discovered
model list so later bind operations can catch typos early. Discovery failures
only emit warnings and do not block saving connection settings. Use
--skip-models when the upstream blocks model discovery or you only want to save
connection settings.
When updating an existing provider, omitted mutable fields keep their current
values: name, api key, headers, and disabled state are preserved unless the
corresponding flag is explicitly passed. Use --clear-headers to remove all saved
extra headers before storing the updated provider. Discovered model catalogs are refreshed when
possible. If connection details changed but discovery was skipped or failed, any
existing model catalog is kept only as untrusted metadata so later validation no
longer relies on stale entries. Repeated --header KEY=VALUE entries replace the
stored header map for this command invocation.
Typical next step: run ocswitch provider list or bind the provider to an alias.`,
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1
ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
ocswitch provider add --id relay --base-url https://example.com/v1 --api-key sk-example --header X-Token=abc --header X-Workspace=my-team
ocswitch provider add --id relay --base-url https://example.com/v1 --skip-models
ocswitch provider add --id su8 --base-url https://new.example.com/v1`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if id == "" || baseURL == "" { if id == "" || baseURL == "" {
return fmt.Errorf("--id and --base-url are required") return fmt.Errorf("--id and --base-url are required")
@ -45,35 +86,76 @@ func newProviderAddCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
hdrs := map[string]string{} apiKeyChanged := cmd.Flags().Changed("api-key")
headersChanged := cmd.Flags().Changed("header")
clearHeadersRequested := cmd.Flags().Changed("clear-headers") && clearHeaders
disabledChanged := cmd.Flags().Changed("disabled")
var hdrs map[string]string
for _, h := range headers { for _, h := range headers {
k, v, ok := strings.Cut(h, "=") k, v, ok := strings.Cut(h, "=")
if !ok { if !ok {
return fmt.Errorf("invalid --header %q (want KEY=VALUE)", h) return fmt.Errorf("invalid --header %q (want KEY=VALUE)", h)
} }
hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v) key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
return fmt.Errorf("invalid --header %q (header name must not be empty)", h)
}
if hdrs == nil {
hdrs = make(map[string]string, len(headers))
}
hdrs[key] = strings.TrimSpace(v)
} }
p := config.Provider{ p := config.Provider{
ID: id, ID: id,
Name: name, Name: name,
BaseURL: baseURL, BaseURL: config.NormalizeProviderBaseURL(baseURL),
APIKey: apiKey, APIKey: apiKey,
Headers: hdrs, Headers: normalizeProviderHeaders(hdrs),
Disabled: disabled, Disabled: disabled,
} }
connectionChanged := false
if existing := cfg.FindProvider(id); existing != nil { if existing := cfg.FindProvider(id); existing != nil {
if p.Name == "" { if p.Name == "" {
p.Name = existing.Name p.Name = existing.Name
} }
if p.APIKey == "" { if !apiKeyChanged {
p.APIKey = existing.APIKey p.APIKey = existing.APIKey
} }
if len(headers) == 0 && len(existing.Headers) > 0 { if !headersChanged && !clearHeadersRequested && len(existing.Headers) > 0 {
p.Headers = cloneHeaders(existing.Headers) p.Headers = cloneHeaders(existing.Headers)
} }
if !disabled { if !disabledChanged {
p.Disabled = existing.Disabled p.Disabled = existing.Disabled
} }
p.Models = append([]string(nil), existing.Models...)
p.ModelsSource = existing.ModelsSource
connectionChanged = !providerConnectionEqual(*existing, p)
}
if !skipModels {
models, err := opencode.FetchProviderModels(p.BaseURL, p.APIKey, p.Headers)
if err != nil {
if connectionChanged {
p.Models = append([]string(nil), p.Models...)
p.ModelsSource = ""
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed and model discovery failed; keeping existing model catalog as untrusted")
}
fmt.Fprintf(cmd.ErrOrStderr(), "warning: could not discover provider models: %v\n", err)
} else if normalized := config.NormalizeProviderModels(models); len(normalized) > 0 {
p.Models = normalized
p.ModelsSource = "discovered"
} else {
if connectionChanged {
p.Models = append([]string(nil), p.Models...)
p.ModelsSource = ""
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed and model discovery returned no models; keeping existing model catalog as untrusted")
} else {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider model discovery returned no models; keeping existing model catalog")
}
}
} else if connectionChanged {
p.Models = append([]string(nil), p.Models...)
p.ModelsSource = ""
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed with --skip-models; keeping existing model catalog as untrusted")
} }
cfg.UpsertProvider(p) cfg.UpsertProvider(p)
if err := cfg.Save(); err != nil { if err := cfg.Save(); err != nil {
@ -84,6 +166,9 @@ func newProviderAddCmd() *cobra.Command {
state = "disabled" state = "disabled"
} }
fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q [%s] → %s\n", id, state, baseURL) fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q [%s] → %s\n", id, state, baseURL)
if !skipModels && p.ModelsSource == "discovered" {
fmt.Fprintf(cmd.OutOrStdout(), " discovered %d model(s)\n", len(p.Models))
}
return nil return nil
}, },
} }
@ -92,10 +177,14 @@ func newProviderAddCmd() *cobra.Command {
cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)") cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)")
cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key") cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key")
cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)") cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)")
cmd.Flags().BoolVar(&clearHeaders, "clear-headers", false, "remove all saved extra headers before applying updates")
cmd.Flags().BoolVar(&disabled, "disabled", false, "save provider in disabled state") cmd.Flags().BoolVar(&disabled, "disabled", false, "save provider in disabled state")
cmd.Flags().BoolVar(&skipModels, "skip-models", false, "skip provider /v1/models discovery")
if err := cmd.Flags().SetAnnotation("skip-models", cobra.BashCompOneRequiredFlag, []string{"false"}); err != nil {
panic(err)
}
return cmd return cmd
} }
func cloneHeaders(in map[string]string) map[string]string { func cloneHeaders(in map[string]string) map[string]string {
if len(in) == 0 { if len(in) == 0 {
return nil return nil
@ -111,24 +200,31 @@ func newProviderListCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List configured providers", Short: "List configured providers",
Long: `provider list prints the providers currently stored in local ocswitch config.
Output is inspection-oriented: provider ids, enabled state, base URLs, and
redacted API keys are shown so you can confirm what was saved or imported before
binding aliases.
This command does not modify config and does not contact upstream providers.`,
Example: ` ocswitch provider list
ocswitch --config /path/to/config.json provider list`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() providers, err := appService().ListProviders(cmd.Context())
if err != nil { if err != nil {
return err return err
} }
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
if len(providers) == 0 { if len(providers) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)") fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
return nil return nil
} }
for _, p := range providers { for _, p := range providers {
key := "(none)" key := "(none)"
if p.APIKey != "" { if p.APIKeySet {
key = maskKey(p.APIKey) key = p.APIKeyMasked
} }
state := "enabled" state := "enabled"
if !p.IsEnabled() { if p.Disabled {
state = "disabled" state = "disabled"
} }
fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key) fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key)
@ -155,6 +251,16 @@ func newProviderStateCmd(use string, disabled bool) *cobra.Command {
Use: use + " <id>", Use: use + " <id>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state", Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state",
Long: fmt.Sprintf(`provider %s flips one provider's disabled state in local ocswitch config.
It changes routing eligibility for every alias target that references this
provider, but it does not rewrite alias target enabled flags. This matters when
the same provider is shared across multiple aliases.
This command writes only the ocswitch config file and does not test upstream
reachability. Typical next step: run ocswitch doctor to confirm routable aliases.`, use),
Example: fmt.Sprintf(` ocswitch provider %s <id>
ocswitch doctor`, use),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -181,6 +287,16 @@ func newProviderRemoveCmd() *cobra.Command {
Use: "remove <id>", Use: "remove <id>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)", Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)",
Long: `provider remove deletes one provider from local ocswitch config.
It does not automatically clean alias target references that still point at the
removed provider. If aliases still reference it, doctor will report invalid
config and those aliases will not be routable.
Typical follow-up: inspect aliases, unbind stale targets, then run ocswitch doctor.`,
Example: ` ocswitch provider remove su8
ocswitch alias list
ocswitch doctor`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -204,13 +320,35 @@ func newProviderImportCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "import-opencode", Use: "import-opencode",
Short: "Import @ai-sdk/openai custom providers from an OpenCode config file", Short: "Import @ai-sdk/openai custom providers from an OpenCode config file",
Long: `provider import-opencode reads an OpenCode config file and copies supported
custom providers into local ocswitch config.
By default it reads the global user OpenCode config resolved in precedence order
opencode.jsonc > opencode.json > config.json under ~/.config/opencode (XDG
aware). It does not follow OPENCODE_CONFIG_DIR for this default source; use
--from when you want a different file.
Only config-defined @ai-sdk/openai custom providers with baseURL are imported.
Imported baseURL values must still satisfy the local /v1 requirement. Providers
with an empty apiKey are allowed and kept as-is. Unsupported provider shapes are
skipped by design. Existing ocswitch providers are skipped unless --overwrite is
given.
Typical next step: run ocswitch provider list, then create aliases and bindings.`,
Example: ` ocswitch provider import-opencode
ocswitch provider import-opencode --from /path/to/opencode.jsonc
ocswitch provider import-opencode --overwrite`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fromChanged := cmd.Flags().Changed("from")
if srcPath == "" { if srcPath == "" {
p, existed := opencode.ResolveGlobalConfigPath() p, existed := opencode.ResolveGlobalConfigPath()
if !existed { if !existed {
return fmt.Errorf("no OpenCode config found at %s; use --from to specify", p) return fmt.Errorf("no OpenCode config found at %s; use --from to specify", p)
} }
srcPath = p srcPath = p
} else if fromChanged {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("read %s: %w", srcPath, err)
}
} }
raw, err := opencode.Load(srcPath) raw, err := opencode.Load(srcPath)
if err != nil { if err != nil {
@ -233,15 +371,23 @@ func newProviderImportCmd() *cobra.Command {
fmt.Fprintf(cmd.OutOrStdout(), "skip %q (already exists, use --overwrite)\n", ip.ID) fmt.Fprintf(cmd.OutOrStdout(), "skip %q (already exists, use --overwrite)\n", ip.ID)
continue continue
} }
cfg.UpsertProvider(config.Provider{ baseURL := config.NormalizeProviderBaseURL(ip.BaseURL)
ID: ip.ID, if err := config.ValidateProviderBaseURL(baseURL); err != nil {
Name: ip.Name, skipped++
BaseURL: ip.BaseURL, fmt.Fprintf(cmd.OutOrStdout(), "skip %q (invalid baseURL %q: %v)\n", ip.ID, ip.BaseURL, err)
APIKey: ip.APIKey, continue
Disabled: false, }
existing := cfg.FindProvider(ip.ID)
merged := mergeImportedProvider(existing, opencode.ImportableProvider{
ID: ip.ID,
Name: ip.Name,
BaseURL: baseURL,
APIKey: ip.APIKey,
Models: ip.Models,
}) })
cfg.UpsertProvider(merged)
imported++ imported++
fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.Models, ",")) fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, baseURL, strings.Join(merged.Models, ","))
} }
if imported > 0 { if imported > 0 {
if err := cfg.Save(); err != nil { if err := cfg.Save(); err != nil {
@ -257,6 +403,79 @@ func newProviderImportCmd() *cobra.Command {
return cmd return cmd
} }
func normalizeProviderHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
continue
}
out[key] = strings.TrimSpace(v)
}
if len(out) == 0 {
return nil
}
return out
}
func providerConnectionEqual(a, b config.Provider) bool {
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
a.APIKey == b.APIKey &&
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
}
func providerCatalogEndpointEqual(a, b config.Provider) bool {
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
a.APIKey == b.APIKey &&
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
}
func mergeImportedProvider(existing *config.Provider, ip opencode.ImportableProvider) config.Provider {
importedModels := config.NormalizeProviderModels(ip.Models)
merged := config.Provider{
ID: ip.ID,
Name: ip.Name,
BaseURL: config.NormalizeProviderBaseURL(ip.BaseURL),
APIKey: ip.APIKey,
Models: importedModels,
ModelsSource: "imported",
}
if len(importedModels) == 0 {
merged.ModelsSource = ""
}
if existing == nil {
return merged
}
merged.Headers = cloneHeaders(existing.Headers)
merged.Disabled = existing.Disabled
if merged.Name == "" {
merged.Name = existing.Name
}
if existing.ModelsSource == "discovered" {
prospective := merged
prospective.Headers = cloneHeaders(existing.Headers)
prospective.Disabled = existing.Disabled
if providerCatalogEndpointEqual(*existing, prospective) {
merged.Models = append([]string(nil), existing.Models...)
merged.ModelsSource = existing.ModelsSource
return merged
}
if len(importedModels) == 0 {
merged.Models = append([]string(nil), existing.Models...)
merged.ModelsSource = ""
return merged
}
}
if len(importedModels) == 0 {
merged.Models = nil
merged.ModelsSource = ""
}
return merged
}
// maskKey redacts middle characters of an API key for display. // maskKey redacts middle characters of an API key for display.
func maskKey(k string) string { func maskKey(k string) string {
if len(k) <= 8 { if len(k) <= 8 {

View File

@ -0,0 +1,49 @@
package cli
import (
"fmt"
"sort"
"strings"
)
func parseProviderModelRef(value string) (provider string, model string, ok bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", "", false
}
provider, model, ok = strings.Cut(trimmed, "/")
if !ok {
return "", "", false
}
provider = strings.TrimSpace(provider)
model = strings.TrimSpace(model)
if provider == "" || model == "" || strings.Contains(provider, "/") {
return "", "", false
}
return provider, model, true
}
func availableProviderModelRefs(models []string, providerID string) []string {
if len(models) == 0 {
return nil
}
refs := make([]string, 0, len(models))
for _, model := range models {
refs = append(refs, providerID+"/"+model)
}
sort.Strings(refs)
return refs
}
func validateProviderModelKnown(providerID string, known []string, source string, model string) error {
if source != "discovered" || len(known) == 0 {
return nil
}
for _, item := range known {
if item == model {
return nil
}
}
choices := availableProviderModelRefs(known, providerID)
return fmt.Errorf("model %q is not in provider %q discovered models; available: %s", model, providerID, strings.Join(choices, ", "))
}

View File

@ -0,0 +1,32 @@
package cli
import "testing"
func TestParseProviderModelRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantProvider string
wantModel string
wantOK bool
}{
{name: "simple", input: "codex/GPT-5.4", wantProvider: "codex", wantModel: "GPT-5.4", wantOK: true},
{name: "trimmed", input: " codex / GPT-5.4 ", wantProvider: "codex", wantModel: "GPT-5.4", wantOK: true},
{name: "model contains slash", input: "relay/openrouter/google/gemini-2.5-pro", wantProvider: "relay", wantModel: "openrouter/google/gemini-2.5-pro", wantOK: true},
{name: "missing slash", input: "gpt-5.4", wantOK: false},
{name: "missing provider", input: "/gpt-5.4", wantOK: false},
{name: "missing model", input: "codex/", wantOK: false},
{name: "nested provider", input: "a/b/c", wantProvider: "a", wantModel: "b/c", wantOK: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, model, ok := parseProviderModelRef(tt.input)
if ok != tt.wantOK || provider != tt.wantProvider || model != tt.wantModel {
t.Fatalf("parseProviderModelRef(%q) = %q, %q, %v", tt.input, provider, model, ok)
}
})
}
}

View File

@ -1,4 +1,4 @@
// Package cli wires the olpx cobra command tree. // Package cli wires the ocswitch cobra command tree.
package cli package cli
import ( import (
@ -7,27 +7,55 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config"
) )
// configPath is populated from the global --config flag. // configPath is populated from the global --config flag.
var configPath string var configPath string
// loadCfg opens the active olpx config, with the selected path. // loadCfg opens the active ocswitch config, with the selected path.
func loadCfg() (*config.Config, error) { func loadCfg() (*config.Config, error) {
return config.Load(configPath) return config.Load(configPath)
} }
// NewRootCmd builds the root olpx command. func appService() *app.Service {
return app.NewService(configPath)
}
// NewRootCmd builds the root ocswitch command.
func NewRootCmd(version string) *cobra.Command { func NewRootCmd(version string) *cobra.Command {
root := &cobra.Command{ root := &cobra.Command{
Use: "olpx", Use: config.AppName,
Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode", Short: "OpenCode Provider Switch CLI: local alias + failover proxy for OpenCode",
Long: `ocswitch is a local OpenCode proxy that exposes stable aliases as ocswitch/<alias>
while routing each alias to one or more upstream provider/model targets.
Typical workflow:
1. add or import upstream providers into local ocswitch config
2. create aliases and bind ordered targets
3. run doctor for static validation
4. run opencode sync to write provider.ocswitch into OpenCode config
5. run serve to accept local /v1/responses traffic
ocswitch writes only its own local config unless a command explicitly says it also
writes OpenCode config. For exact command behavior, defaults, and side effects,
prefer command-local --help over README summaries.`,
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4
ocswitch doctor
ocswitch opencode sync
ocswitch serve
ocswitch provider import-opencode
ocswitch provider list
ocswitch opencode sync --dry-run`,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: false, SilenceErrors: false,
Version: version, Version: version,
} }
root.PersistentFlags().StringVar(&configPath, "config", "", "path to olpx config.json (default: $XDG_CONFIG_HOME/olpx/config.json)") root.PersistentFlags().StringVar(&configPath, "config", "", fmt.Sprintf("path to %s config.json (default: $%s, else $XDG_CONFIG_HOME/%s/config.json, else ~/.config/%s/config.json)", config.AppName, config.ConfigEnvVar, config.ConfigDirName, config.ConfigDirName))
root.AddCommand(newServeCmd()) root.AddCommand(newServeCmd())
root.AddCommand(newDoctorCmd()) root.AddCommand(newDoctorCmd())

View File

@ -1,33 +1,40 @@
package cli package cli
import ( import (
"context"
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/proxy"
) )
func newServeCmd() *cobra.Command { func newServeCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "serve", Use: "serve",
Short: "Run the local olpx proxy (alias -> failover upstream)", Short: "Run the local ocswitch proxy (alias -> failover upstream)",
Long: `serve starts the long-running local ocswitch proxy using the current local config.
It reads local alias/provider configuration, validates that config, and then
accepts OpenAI Responses traffic at the configured local base URL. With default
settings the proxy listens on http://127.0.0.1:9982/v1 and expects the local API
key ocswitch-local.
serve does not rewrite config files. Run doctor and opencode sync first so
OpenCode can see the same aliases that the proxy can route.`,
Example: ` ocswitch serve
ocswitch --config /path/to/config.json serve`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if errs := cfg.Validate(); len(errs) > 0 {
for _, e := range errs {
cmd.PrintErrln("config error:", e)
}
return errs[0]
}
srv := proxy.New(cfg)
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
return srv.ListenAndServe(ctx) svc := appService()
if err := svc.StartProxy(context.Background()); err != nil {
return err
}
go func() {
<-ctx.Done()
_ = svc.StopProxy(context.Background())
}()
return svc.WaitProxy(context.Background())
}, },
} }
} }

View File

@ -1,15 +1,25 @@
// Package config manages the local olpx JSON config file. // Package config manages the local ocswitch JSON config file.
package config package config
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"github.com/Apale7/opencode-provider-switch/internal/fileutil"
)
const (
AppName = "ocswitch"
ConfigEnvVar = "OCSWITCH_CONFIG"
ConfigDirName = "ocswitch"
DefaultLocalAPIKey = "ocswitch-local"
) )
// Target is one concrete upstream candidate for an alias. // Target is one concrete upstream candidate for an alias.
@ -29,12 +39,14 @@ type Alias struct {
// Provider is one upstream OpenAI-compatible endpoint. // Provider is one upstream OpenAI-compatible endpoint.
type Provider struct { type Provider struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Headers map[string]string `json:"headers,omitempty"` Headers map[string]string `json:"headers,omitempty"`
Disabled bool `json:"disabled,omitempty"` Models []string `json:"models,omitempty"`
ModelsSource string `json:"models_source,omitempty"`
Disabled bool `json:"disabled,omitempty"`
} }
// Server holds proxy listen settings. // Server holds proxy listen settings.
@ -44,9 +56,17 @@ type Server struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
} }
// Config is the on-disk olpx config. // Desktop holds desktop-shell user preferences.
type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"`
}
// Config is the on-disk ocswitch config.
type Config struct { type Config struct {
Server Server `json:"server"` Server Server `json:"server"`
Desktop Desktop `json:"desktop,omitempty"`
Providers []Provider `json:"providers"` Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"` Aliases []Alias `json:"aliases"`
@ -59,10 +79,16 @@ func (p Provider) IsEnabled() bool {
return !p.Disabled return !p.Disabled
} }
// NormalizeProviderBaseURL canonicalizes equivalent /v1 roots for comparisons
// and persisted config values.
func NormalizeProviderBaseURL(baseURL string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
}
// ValidateProviderBaseURL checks the MVP requirement that upstream base URLs // ValidateProviderBaseURL checks the MVP requirement that upstream base URLs
// point at an OpenAI-compatible /v1 root. // point at an OpenAI-compatible /v1 root.
func ValidateProviderBaseURL(baseURL string) error { func ValidateProviderBaseURL(baseURL string) error {
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") trimmed := NormalizeProviderBaseURL(baseURL)
if trimmed == "" { if trimmed == "" {
return fmt.Errorf("missing base_url") return fmt.Errorf("missing base_url")
} }
@ -78,26 +104,27 @@ func Default() *Config {
Server: Server{ Server: Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 9982, Port: 9982,
APIKey: "olpx-local", APIKey: DefaultLocalAPIKey,
}, },
Desktop: Desktop{},
Providers: []Provider{}, Providers: []Provider{},
Aliases: []Alias{}, Aliases: []Alias{},
} }
} }
// DefaultPath returns ~/.config/olpx/config.json (XDG aware). // DefaultPath returns ~/.config/ocswitch/config.json (XDG aware).
func DefaultPath() string { func DefaultPath() string {
if p := os.Getenv("OLPX_CONFIG"); p != "" { if p := os.Getenv(ConfigEnvVar); p != "" {
return p return p
} }
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "olpx", "config.json") return filepath.Join(xdg, ConfigDirName, "config.json")
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "olpx-config.json" return AppName + "-config.json"
} }
return filepath.Join(home, ".config", "olpx", "config.json") return filepath.Join(home, ".config", ConfigDirName, "config.json")
} }
// Load reads the config at path. Missing file returns a default config anchored to path. // Load reads the config at path. Missing file returns a default config anchored to path.
@ -127,52 +154,60 @@ func Load(path string) (*Config, error) {
c.Server.Port = 9982 c.Server.Port = 9982
} }
if c.Server.APIKey == "" { if c.Server.APIKey == "" {
c.Server.APIKey = "olpx-local" c.Server.APIKey = DefaultLocalAPIKey
} }
c.path = path c.path = path
return c, nil return c, nil
} }
// Path returns the on-disk path of this config. // Path returns the on-disk path of this config.
func (c *Config) Path() string { return c.path } func (c *Config) Path() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.path
}
// Save writes config atomically. // Save writes config atomically.
func (c *Config) Save() error { func (c *Config) Save() error {
c.mu.RLock() c.mu.Lock()
defer c.mu.RUnlock() defer c.mu.Unlock()
if c.path == "" { if c.path == "" {
c.path = DefaultPath() c.path = DefaultPath()
} }
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { path := c.path
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err) return fmt.Errorf("mkdir: %w", err)
} }
// sort for stability return fileutil.WithLockedFile(path, func() error {
providers := append([]Provider(nil), c.Providers...) providers := cloneProviders(c.Providers)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
aliases := append([]Alias(nil), c.Aliases...) aliases := cloneAliases(c.Aliases)
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
snap := struct { snap := struct {
Server Server `json:"server"` Server Server `json:"server"`
Providers []Provider `json:"providers"` Desktop Desktop `json:"desktop,omitempty"`
Aliases []Alias `json:"aliases"` Providers []Provider `json:"providers"`
}{c.Server, providers, aliases} Aliases []Alias `json:"aliases"`
data, err := json.MarshalIndent(snap, "", " ") }{c.Server, c.Desktop, providers, aliases}
if err != nil { data, err := json.MarshalIndent(snap, "", " ")
return fmt.Errorf("marshal: %w", err) if err != nil {
} return fmt.Errorf("marshal: %w", err)
data = append(data, '\n') }
tmp := c.path + ".tmp" data = append(data, '\n')
if err := os.WriteFile(tmp, data, 0o600); err != nil { return fileutil.AtomicWriteFile(path, data, 0o600)
return fmt.Errorf("write tmp: %w", err) })
}
return os.Rename(tmp, c.path)
} }
// FindProvider returns the provider with matching id or nil. // FindProvider returns the provider with matching id or nil.
func (c *Config) FindProvider(id string) *Provider { func (c *Config) FindProvider(id string) *Provider {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.findProviderLocked(id) p := c.findProviderLocked(id)
if p == nil {
return nil
}
clone := cloneProvider(*p)
return &clone
} }
func (c *Config) findProviderLocked(id string) *Provider { func (c *Config) findProviderLocked(id string) *Provider {
@ -188,13 +223,14 @@ func (c *Config) findProviderLocked(id string) *Provider {
func (c *Config) UpsertProvider(p Provider) { func (c *Config) UpsertProvider(p Provider) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
cloned := cloneProvider(p)
for i := range c.Providers { for i := range c.Providers {
if c.Providers[i].ID == p.ID { if c.Providers[i].ID == p.ID {
c.Providers[i] = p c.Providers[i] = cloned
return return
} }
} }
c.Providers = append(c.Providers, p) c.Providers = append(c.Providers, cloned)
} }
// RemoveProvider deletes a provider and returns true if removed. // RemoveProvider deletes a provider and returns true if removed.
@ -216,7 +252,8 @@ func (c *Config) FindAlias(name string) *Alias {
defer c.mu.RUnlock() defer c.mu.RUnlock()
for i := range c.Aliases { for i := range c.Aliases {
if c.Aliases[i].Alias == name { if c.Aliases[i].Alias == name {
return &c.Aliases[i] clone := cloneAlias(c.Aliases[i])
return &clone
} }
} }
return nil return nil
@ -226,13 +263,14 @@ func (c *Config) FindAlias(name string) *Alias {
func (c *Config) UpsertAlias(a Alias) { func (c *Config) UpsertAlias(a Alias) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
cloned := cloneAlias(a)
for i := range c.Aliases { for i := range c.Aliases {
if c.Aliases[i].Alias == a.Alias { if c.Aliases[i].Alias == a.Alias {
c.Aliases[i] = a c.Aliases[i] = cloned
return return
} }
} }
c.Aliases = append(c.Aliases, a) c.Aliases = append(c.Aliases, cloned)
} }
// RemoveAlias deletes an alias. // RemoveAlias deletes an alias.
@ -356,6 +394,26 @@ func (c *Config) Validate() []error {
if err := ValidateProviderBaseURL(p.BaseURL); err != nil { if err := ValidateProviderBaseURL(p.BaseURL); err != nil {
errs = append(errs, fmt.Errorf("provider %q %s", p.ID, err)) errs = append(errs, fmt.Errorf("provider %q %s", p.ID, err))
} }
seenModels := map[string]bool{}
for _, model := range p.Models {
trimmed := strings.TrimSpace(model)
if trimmed == "" {
errs = append(errs, fmt.Errorf("provider %q has empty model entry", p.ID))
continue
}
if seenModels[trimmed] {
errs = append(errs, fmt.Errorf("provider %q has duplicate model %q", p.ID, trimmed))
continue
}
seenModels[trimmed] = true
}
validModelsSource := p.ModelsSource == "" || p.ModelsSource == "discovered" || p.ModelsSource == "imported"
if !validModelsSource {
errs = append(errs, fmt.Errorf("provider %q has invalid models_source %q", p.ID, p.ModelsSource))
}
if validModelsSource && p.ModelsSource != "" && len(p.Models) == 0 {
errs = append(errs, fmt.Errorf("provider %q has models_source %q but no models", p.ID, p.ModelsSource))
}
} }
seen := map[string]bool{} seen := map[string]bool{}
for _, a := range c.Aliases { for _, a := range c.Aliases {
@ -385,5 +443,66 @@ func (c *Config) Validate() []error {
if c.Server.Port <= 0 || c.Server.Port > 65535 { if c.Server.Port <= 0 || c.Server.Port > 65535 {
errs = append(errs, fmt.Errorf("invalid server port %d", c.Server.Port)) errs = append(errs, fmt.Errorf("invalid server port %d", c.Server.Port))
} }
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))
}
return errs return errs
} }
func isLoopbackHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" || strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func cloneProvider(p Provider) Provider {
p.Headers = cloneStringMap(p.Headers)
p.Models = cloneStrings(p.Models)
return p
}
func cloneAlias(a Alias) Alias {
a.Targets = cloneTargets(a.Targets)
return a
}
func cloneProviders(in []Provider) []Provider {
out := make([]Provider, len(in))
for i := range in {
out[i] = cloneProvider(in[i])
}
return out
}
func cloneAliases(in []Alias) []Alias {
out := make([]Alias, len(in))
for i := range in {
out[i] = cloneAlias(in[i])
}
return out
}
func cloneTargets(in []Target) []Target {
out := make([]Target, len(in))
copy(out, in)
return out
}
func cloneStrings(in []string) []string {
out := make([]string, len(in))
copy(out, in)
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}

View File

@ -1,7 +1,14 @@
package config package config
import ( import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing" "testing"
"time"
) )
func TestValidateProviderBaseURL(t *testing.T) { func TestValidateProviderBaseURL(t *testing.T) {
@ -39,6 +46,14 @@ func TestValidateProviderBaseURL(t *testing.T) {
} }
} }
func TestNormalizeProviderBaseURL(t *testing.T) {
t.Parallel()
if got := NormalizeProviderBaseURL(" https://example.com/v1/ "); got != "https://example.com/v1" {
t.Fatalf("NormalizeProviderBaseURL() = %q", got)
}
}
func TestAvailableTargetsSkipsDisabledAndMissingProviders(t *testing.T) { func TestAvailableTargetsSkipsDisabledAndMissingProviders(t *testing.T) {
t.Parallel() t.Parallel()
@ -89,11 +104,27 @@ func TestAvailableAliasNamesOnlyReturnsRoutableAliases(t *testing.T) {
} }
} }
func TestValidateRejectsDefaultKeyOnNonLoopbackHost(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{Host: "0.0.0.0", Port: 9982, APIKey: DefaultLocalAPIKey},
}
errs := cfg.Validate()
if len(errs) != 1 {
t.Fatalf("Validate() errors = %v, want 1 error", errs)
}
if !strings.Contains(errs[0].Error(), "must not use the default value") {
t.Fatalf("Validate() error = %q", errs[0].Error())
}
}
func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) { func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) {
t.Parallel() t.Parallel()
cfg := &Config{ cfg := &Config{
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: "olpx-local"}, Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey},
Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", Disabled: true}}, Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", Disabled: true}},
Aliases: []Alias{{ Aliases: []Alias{{
Alias: "gpt-5.4", Alias: "gpt-5.4",
@ -110,3 +141,134 @@ func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) {
t.Fatalf("Validate() error = %q", got) t.Fatalf("Validate() error = %q", got)
} }
} }
func TestValidateRejectsInvalidModelsSource(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey},
Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", ModelsSource: "manual"}},
}
err := cfg.Validate()
if len(err) != 1 {
t.Fatalf("Validate() errors = %v, want 1 error", err)
}
if got := err[0].Error(); got != `provider "p1" has invalid models_source "manual"` {
t.Fatalf("Validate() error = %q", got)
}
}
func TestValidateRejectsModelsSourceWithoutModels(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey},
Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", ModelsSource: "discovered"}},
}
err := cfg.Validate()
if len(err) != 1 {
t.Fatalf("Validate() errors = %v, want 1 error", err)
}
if got := err[0].Error(); got != `provider "p1" has models_source "discovered" but no models` {
t.Fatalf("Validate() error = %q", got)
}
}
func TestSavePreservesEmptyCollectionsAsArrays(t *testing.T) {
t.Parallel()
cfg := Default()
cfg.path = t.TempDir() + "/config.json"
if err := cfg.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
loaded, err := Load(cfg.path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if loaded.Providers == nil || loaded.Aliases == nil {
t.Fatalf("round-trip nil slices: providers=%#v aliases=%#v", loaded.Providers, loaded.Aliases)
}
var raw map[string]any
data, err := os.ReadFile(cfg.path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if _, ok := raw["providers"].([]any); !ok {
t.Fatalf("providers JSON = %#v, want array", raw["providers"])
}
if _, ok := raw["aliases"].([]any); !ok {
t.Fatalf("aliases JSON = %#v, want array", raw["aliases"])
}
}
func TestSaveLinearizesConcurrentWriters(t *testing.T) {
t.Parallel()
cfg := Default()
cfg.path = filepath.Join(t.TempDir(), "config.json")
lockFile, err := os.OpenFile(cfg.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
t.Fatalf("OpenFile(lock): %v", err)
}
defer lockFile.Close()
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil {
t.Fatalf("Flock(lock): %v", err)
}
startedFirst := make(chan struct{})
secondDone := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
cfg.UpsertProvider(Provider{ID: "first", BaseURL: "https://first.example.com/v1"})
close(startedFirst)
if err := cfg.Save(); err != nil {
t.Errorf("first Save() error = %v", err)
}
}()
<-startedFirst
time.Sleep(20 * time.Millisecond)
go func() {
defer wg.Done()
cfg.UpsertProvider(Provider{ID: "second", BaseURL: "https://second.example.com/v1"})
if err := cfg.Save(); err != nil {
t.Errorf("second Save() error = %v", err)
}
close(secondDone)
}()
select {
case <-secondDone:
t.Fatal("second Save() completed before external file lock was released")
case <-time.After(20 * time.Millisecond):
}
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil {
t.Fatalf("Flock(unlock): %v", err)
}
wg.Wait()
loaded, err := Load(cfg.path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if loaded.FindProvider("first") == nil {
t.Fatalf("final config = %#v, want first provider persisted", loaded.Providers)
}
if loaded.FindProvider("second") == nil {
t.Fatalf("final config = %#v, want latest provider persisted", loaded.Providers)
}
}

View File

@ -0,0 +1,28 @@
package config
import (
"slices"
"sort"
"strings"
)
func NormalizeProviderModels(models []string) []string {
if len(models) == 0 {
return nil
}
seen := make(map[string]bool, len(models))
out := make([]string, 0, len(models))
for _, model := range models {
trimmed := strings.TrimSpace(model)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
sort.Strings(out)
return slices.Clip(out)
}

View File

@ -0,0 +1,16 @@
package config
import (
"reflect"
"testing"
)
func TestNormalizeProviderModels(t *testing.T) {
t.Parallel()
got := NormalizeProviderModels([]string{" GPT-5.4 ", "gpt-5.4", "", "GPT-5.4"})
want := []string{"GPT-5.4", "gpt-5.4"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("NormalizeProviderModels() = %#v, want %#v", got, want)
}
}

167
internal/desktop/app.go Normal file
View File

@ -0,0 +1,167 @@
package desktop
import (
"context"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
// App is the desktop-shell composition root. Native shell integrations are kept
// out of internal/app so CLI and GUI can share the same workflows.
type App struct {
service *app.Service
bindings *Bindings
tray *Tray
notify *Notifier
auto *AutoStart
ctx context.Context
version string
}
func New(configPath string) *App {
svc := app.NewService(configPath)
instance := &App{service: svc}
instance.bindings = NewBindings(svc)
instance.tray = NewTray(svc)
instance.notify = NewNotifier(svc)
instance.auto = NewAutoStart(svc)
return instance
}
func (a *App) SetVersion(version string) {
a.version = version
}
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.tray.Attach(ctx)
a.notify.Attach(ctx)
a.auto.Attach(ctx)
_ = a.SyncDesktopPreferences(ctx)
}
func (a *App) BeforeClose(ctx context.Context) bool {
a.ctx = ctx
prevent, _ := a.tray.BeforeClose(ctx)
return prevent
}
func (a *App) Shutdown(ctx context.Context) {
a.ctx = ctx
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = a.service.StopProxy(shutdownCtx)
a.tray.Detach()
a.notify.Detach()
a.auto.Detach()
}
func (a *App) SyncDesktopPreferences(ctx context.Context) error {
prefs, err := a.bindings.GetDesktopPrefs(ctx)
if err != nil {
return err
}
if err := a.auto.Sync(ctx, prefs); err != nil {
return err
}
a.tray.Sync(ctx, prefs)
return nil
}
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
if err != nil {
return app.DesktopPrefsView{}, err
}
if err := a.auto.Sync(ctx, prefs); err != nil {
return app.DesktopPrefsView{}, err
}
a.tray.Sync(ctx, prefs)
return prefs, nil
}
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
return a.SaveDesktopPrefs(a.callContext(), in)
}
func (a *App) Meta() map[string]string {
return map[string]string{
"version": a.version,
"shell": a.shellName(),
}
}
func (a *App) Overview() (app.Overview, error) {
return a.bindings.GetOverview(a.callContext())
}
func (a *App) Providers() ([]app.ProviderView, error) {
return a.bindings.ListProviders(a.callContext())
}
func (a *App) Aliases() ([]app.AliasView, error) {
return a.bindings.ListAliases(a.callContext())
}
func (a *App) DoctorRun() (app.DoctorRunResult, error) {
report, err := a.bindings.RunDoctor(a.callContext())
return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil
}
func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
return a.bindings.GetProxyStatus(a.callContext())
}
func (a *App) StartProxy() (app.ProxyStatusView, error) {
return a.bindings.StartProxy(a.callContext())
}
func (a *App) StopProxy() (app.ProxyStatusView, error) {
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
defer cancel()
return a.bindings.StopProxy(ctx)
}
func (a *App) DesktopPrefs() (app.DesktopPrefsView, error) {
return a.bindings.GetDesktopPrefs(a.callContext())
}
func (a *App) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
return a.bindings.PreviewOpenCodeSync(a.callContext(), in)
}
func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) {
return a.bindings.SyncOpenCode(a.callContext(), in)
}
func (a *App) callContext() context.Context {
if a.ctx != nil {
return a.ctx
}
return context.Background()
}
func (a *App) shellName() string {
if a.ctx != nil {
return "wails"
}
return "browser"
}
func (a *App) Service() *app.Service {
return a.service
}
func (a *App) Bindings() *Bindings {
return a.bindings
}
func (a *App) Tray() *Tray {
return a.tray
}
func (a *App) AutoStart() *AutoStart {
return a.auto
}

View File

@ -0,0 +1,98 @@
package desktop
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
// AutoStart manages real launch-at-login integration where the platform permits
// it. This implementation currently targets Linux XDG autostart.
type AutoStart struct {
service *app.Service
ctx context.Context
}
func NewAutoStart(service *app.Service) *AutoStart {
return &AutoStart{service: service}
}
func (a *AutoStart) Attach(ctx context.Context) {
a.ctx = ctx
}
func (a *AutoStart) Detach() {
a.ctx = nil
}
func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error {
_ = ctx
if runtime.GOOS != "linux" {
return nil
}
entryPath, err := a.entryPath()
if err != nil {
return err
}
if prefs.LaunchAtLogin {
return a.writeLinuxEntry(entryPath)
}
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove autostart entry: %w", err)
}
return nil
}
func (a *AutoStart) entryPath() (string, error) {
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home for autostart: %w", err)
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil
}
func (a *AutoStart) writeLinuxEntry(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir autostart dir: %w", err)
}
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve desktop executable: %w", err)
}
configPath := config.DefaultPath()
if a.service != nil {
configPath = a.service.ConfigPath()
}
content := strings.Join([]string{
"[Desktop Entry]",
"Type=Application",
"Version=1.0",
"Name=ocswitch desktop",
"Comment=OpenCode provider switch desktop shell",
fmt.Sprintf("Exec=%s --config %s", shellQuote(execPath), shellQuote(configPath)),
"Terminal=false",
"X-GNOME-Autostart-enabled=true",
"Categories=Network;Development;",
"StartupNotify=false",
"",
}, "\n")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write autostart entry: %w", err)
}
return nil
}
func shellQuote(value string) string {
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
return `"` + replacer.Replace(value) + `"`
}

View File

@ -0,0 +1,59 @@
package desktop
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
func TestAutoStartSyncLinuxWritesDesktopEntry(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("linux-only autostart behavior")
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
auto := NewAutoStart(nil)
if err := auto.Sync(context.Background(), app.DesktopPrefsView{LaunchAtLogin: true}); err != nil {
t.Fatalf("Sync() error = %v", err)
}
entry, err := auto.entryPath()
if err != nil {
t.Fatalf("entryPath() error = %v", err)
}
data, err := os.ReadFile(entry)
if err != nil {
t.Fatalf("os.ReadFile() error = %v", err)
}
text := string(data)
if !strings.Contains(text, "[Desktop Entry]") {
t.Fatalf("desktop entry missing header: %q", text)
}
if !strings.Contains(text, "Name=ocswitch desktop") {
t.Fatalf("desktop entry missing app name: %q", text)
}
if err := auto.Sync(context.Background(), app.DesktopPrefsView{}); err != nil {
t.Fatalf("Sync(remove) error = %v", err)
}
if _, err := os.Stat(entry); !os.IsNotExist(err) {
t.Fatalf("autostart entry still exists at %s", entry)
}
}
func TestShellQuoteEscapesDoubleQuotes(t *testing.T) {
t.Parallel()
quoted := shellQuote(filepath.Join(`/tmp/demo"path`, `bin`))
if !strings.HasPrefix(quoted, `"`) || !strings.HasSuffix(quoted, `"`) {
t.Fatalf("shellQuote() = %q", quoted)
}
if !strings.Contains(quoted, `\\"`) {
t.Fatalf("shellQuote() did not escape quotes: %q", quoted)
}
}

View File

@ -0,0 +1,114 @@
package desktop
import (
"context"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
// Bindings is the thin desktop-callable facade shared by the fallback HTTP shell
// and the Wails bridge.
type Bindings struct {
service *app.Service
}
func NewBindings(service *app.Service) *Bindings {
return &Bindings{service: service}
}
func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) {
return b.service.GetOverview(ctx)
}
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
return b.service.ListProviders(ctx)
}
func (b *Bindings) ListAliases(ctx context.Context) ([]app.AliasView, error) {
return b.service.ListAliases(ctx)
}
func (b *Bindings) RunDoctor(ctx context.Context) (app.DoctorReport, error) {
return b.service.RunDoctor(ctx)
}
func (b *Bindings) SyncOpenCode(ctx context.Context, in app.SyncInput) (app.SyncResult, error) {
return b.service.ApplyOpenCodeSync(ctx, in)
}
func (b *Bindings) PreviewOpenCodeSync(ctx context.Context, in app.SyncInput) (app.SyncPreview, error) {
return b.service.PreviewOpenCodeSync(ctx, in)
}
func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, error) {
return b.service.GetProxyStatus(ctx)
}
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
if err := b.service.StartProxy(ctx); err != nil {
return app.ProxyStatusView{}, err
}
return b.service.GetProxyStatus(ctx)
}
func (b *Bindings) StopProxy(ctx context.Context) (app.ProxyStatusView, error) {
if err := b.service.StopProxy(ctx); err != nil {
return app.ProxyStatusView{}, err
}
return b.service.GetProxyStatus(ctx)
}
func (b *Bindings) Overview() (app.Overview, error) {
return b.GetOverview(context.Background())
}
func (b *Bindings) Providers() ([]app.ProviderView, error) {
return b.ListProviders(context.Background())
}
func (b *Bindings) Aliases() ([]app.AliasView, error) {
return b.ListAliases(context.Background())
}
func (b *Bindings) Doctor() (app.DoctorReport, error) {
return b.RunDoctor(context.Background())
}
func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
return b.GetProxyStatus(context.Background())
}
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
return b.StartProxy(context.Background())
}
func (b *Bindings) StopProxyNow() (app.ProxyStatusView, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return b.StopProxy(ctx)
}
func (b *Bindings) DesktopPrefs() (app.DesktopPrefsView, error) {
return b.GetDesktopPrefs(context.Background())
}
func (b *Bindings) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
return b.SaveDesktopPrefs(context.Background(), in)
}
func (b *Bindings) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
return b.PreviewOpenCodeSync(context.Background(), in)
}
func (b *Bindings) ApplySync(in app.SyncInput) (app.SyncResult, error) {
return b.SyncOpenCode(context.Background(), in)
}
func (b *Bindings) GetDesktopPrefs(ctx context.Context) (app.DesktopPrefsView, error) {
return b.service.GetDesktopPrefs(ctx)
}
func (b *Bindings) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
return b.service.SaveDesktopPrefs(ctx, in)
}

323
internal/desktop/http.go Normal file
View File

@ -0,0 +1,323 @@
package desktop
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
appcore "github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
type RunOptions struct {
ConfigPath string
Version string
ListenAddr string
OpenBrowser bool
ShutdownWait time.Duration
}
type apiEnvelope struct {
Data any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type metaView struct {
Version string `json:"version"`
Shell string `json:"shell"`
URL string `json:"url"`
}
func Run(opts RunOptions) error {
if strings.TrimSpace(opts.ConfigPath) == "" {
opts.ConfigPath = config.DefaultPath()
}
if strings.TrimSpace(opts.ListenAddr) == "" {
opts.ListenAddr = "127.0.0.1:0"
}
if opts.ShutdownWait <= 0 {
opts.ShutdownWait = 5 * time.Second
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
instance := New(opts.ConfigPath)
listener, err := net.Listen("tcp", opts.ListenAddr)
if err != nil {
return fmt.Errorf("listen desktop control panel: %w", err)
}
defer listener.Close()
url := "http://" + listener.Addr().String()
handler, err := newHandler(instance, opts.Version, url)
if err != nil {
return err
}
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- srv.Serve(listener)
}()
fmt.Printf("ocswitch desktop control panel: %s\n", url)
if opts.OpenBrowser {
if err := openBrowser(url); err != nil {
fmt.Fprintf(os.Stderr, "warning: open browser: %v\n", err)
}
}
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), opts.ShutdownWait)
defer cancel()
_ = instance.Service().StopProxy(shutdownCtx)
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func newHandler(instance *App, version string, baseURL string) (http.Handler, error) {
assets, err := frontendassets.DistFS()
if err != nil {
return nil, fmt.Errorf("load web assets: %w", err)
}
api := http.NewServeMux()
b := instance.Bindings()
api.HandleFunc("/api/meta", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
writeJSON(w, http.StatusOK, apiEnvelope{Data: metaView{Version: version, Shell: instance.shellName(), URL: baseURL}})
})
api.HandleFunc("/api/overview", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.GetOverview(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.ListProviders(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.ListAliases(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/desktop-prefs", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := b.GetDesktopPrefs(r.Context())
writeResult(w, data, err)
case http.MethodPost:
var in appcore.DesktopPrefsInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.SaveDesktopPrefs(r.Context(), in)
writeResult(w, data, err)
default:
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
}
})
api.HandleFunc("/api/proxy/status", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.GetProxyStatus(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
data, err := b.StartProxy(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/proxy/stop", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
data, err := b.StopProxy(ctx)
writeResult(w, data, err)
})
api.HandleFunc("/api/doctor", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
data, err := b.RunDoctor(r.Context())
writeJSON(w, http.StatusOK, apiEnvelope{Data: appcore.DoctorRunResult{Report: data, Error: errorString(err)}})
})
api.HandleFunc("/api/opencode-sync/preview", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.SyncInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.PreviewOpenCodeSync(r.Context(), in)
writeResult(w, data, err)
})
api.HandleFunc("/api/opencode-sync/apply", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.SyncInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.SyncOpenCode(r.Context(), in)
writeResult(w, data, err)
})
fileServer := http.FileServer(http.FS(assets))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
api.ServeHTTP(w, r)
return
}
serveSPA(w, r, assets, fileServer)
}), nil
}
func serveSPA(w http.ResponseWriter, r *http.Request, assets fs.FS, next http.Handler) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(assets, path); err == nil {
next.ServeHTTP(w, r)
return
}
r = r.Clone(r.Context())
r.URL.Path = "/index.html"
next.ServeHTTP(w, r)
}
func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()})
return false
}
if len(strings.TrimSpace(string(body))) == 0 {
return true
}
if err := json.Unmarshal(body, dst); err != nil {
writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: "invalid json: " + err.Error()})
return false
}
return true
}
func writeResult(w http.ResponseWriter, data any, err error) {
if err != nil {
writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiEnvelope{Data: data})
}
func writeMethodNotAllowed(w http.ResponseWriter, allowed ...string) {
if len(allowed) > 0 {
w.Header().Set("Allow", strings.Join(allowed, ", "))
}
writeJSON(w, http.StatusMethodNotAllowed, apiEnvelope{Error: "method not allowed"})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func openBrowser(url string) error {
commands := browserCommands(url)
var errs []string
for _, args := range commands {
if _, err := exec.LookPath(args[0]); err != nil {
err = nil
continue
}
if err := exec.Command(args[0], args[1:]...).Start(); err == nil {
return nil
} else {
errs = append(errs, err.Error())
}
}
if len(errs) == 0 {
return fmt.Errorf("no browser launcher found")
}
return fmt.Errorf(strings.Join(errs, "; "))
}
func browserCommands(url string) [][]string {
switch runtime.GOOS {
case "darwin":
return [][]string{{"open", url}}
case "windows":
return [][]string{{"rundll32", "url.dll,FileProtocolHandler", url}}
default:
return [][]string{{"xdg-open", url}, {"gio", "open", url}}
}
}

View File

@ -0,0 +1,104 @@
package desktop
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{
ID: "demo",
Name: "Demo",
BaseURL: "https://example.com/v1",
APIKey: "sk-demo-12345678",
Models: []string{"gpt-4.1-mini"},
})
cfg.UpsertAlias(config.Alias{
Alias: "chat",
DisplayName: "Chat",
Enabled: true,
Targets: []config.Target{{
Provider: "demo",
Model: "gpt-4.1-mini",
Enabled: true,
}},
})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
h, err := newHandler(New(path), "test", "http://127.0.0.1:9982")
if err != nil {
t.Fatalf("newHandler() error = %v", err)
}
t.Run("overview api", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/overview", 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 struct {
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
Aliases []string `json:"availableAliases"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.ProviderCount != 1 || payload.Data.AliasCount != 1 {
t.Fatalf("unexpected counts: %#v", payload.Data)
}
if len(payload.Data.Aliases) != 1 || payload.Data.Aliases[0] != "chat" {
t.Fatalf("unexpected aliases: %#v", payload.Data.Aliases)
}
})
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}`))
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", resp.Code, http.StatusOK)
}
loaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications {
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
}
})
t.Run("serves app shell", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
if !strings.Contains(resp.Body.String(), "ocswitch desktop") {
t.Fatalf("unexpected body = %q", resp.Body.String())
}
})
}

View File

@ -0,0 +1,25 @@
package desktop
import (
"context"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
// Notifier is the future native notification adapter.
type Notifier struct {
service *app.Service
ctx context.Context
}
func NewNotifier(service *app.Service) *Notifier {
return &Notifier{service: service}
}
func (n *Notifier) Attach(ctx context.Context) {
n.ctx = ctx
}
func (n *Notifier) Detach() {
n.ctx = nil
}

View File

@ -0,0 +1,10 @@
//go:build !desktop_wails
package desktop
import "context"
func hideWindow(ctx context.Context) error {
_ = ctx
return nil
}

View File

@ -0,0 +1,14 @@
//go:build desktop_wails
package desktop
import (
"context"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
func hideWindow(ctx context.Context) error {
wruntime.Hide(ctx)
return nil
}

41
internal/desktop/tray.go Normal file
View File

@ -0,0 +1,41 @@
package desktop
import (
"context"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
// Tray holds desktop resident-mode preferences. Wails v2 does not expose a
// stable public tray API in the current pinned version, so this type currently
// manages close-to-background behavior without depending on private APIs.
type Tray struct {
service *app.Service
ctx context.Context
prefs app.DesktopPrefsView
}
func NewTray(service *app.Service) *Tray {
return &Tray{service: service}
}
func (t *Tray) Attach(ctx context.Context) {
t.ctx = ctx
}
func (t *Tray) Detach() {
t.ctx = nil
}
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
_ = ctx
t.prefs = prefs
}
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
_ = ctx
if !t.prefs.MinimizeToTray {
return false, nil
}
return true, hideWindow(ctx)
}

58
internal/desktop/wails.go Normal file
View File

@ -0,0 +1,58 @@
//go:build desktop_wails
package desktop
import (
"context"
"fmt"
"io/fs"
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/linux"
)
func RunWails(configPath string, version string) error {
assets, err := frontendassets.DistFS()
if err != nil {
return err
}
instance := New(configPath)
instance.SetVersion(version)
return wails.Run(&options.App{
Title: "ocswitch desktop",
Width: 1280,
Height: 880,
MinWidth: 980,
MinHeight: 720,
HideWindowOnClose: true,
AssetServer: &assetserver.Options{
Assets: mustFS(assets),
},
Bind: []any{instance},
OnStartup: func(ctx context.Context) {
instance.Startup(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
return instance.BeforeClose(ctx)
},
OnShutdown: func(ctx context.Context) {
instance.Shutdown(ctx)
},
Linux: &linux.Options{
ProgramName: "ocswitch-desktop",
},
})
}
func mustFS(assets fs.FS) fs.FS {
return assets
}
func WailsProjectName() string {
return fmt.Sprintf("%s desktop", "ocswitch")
}

View File

@ -0,0 +1,96 @@
package fileutil
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
type LockedFile struct {
file *os.File
}
func WithLockedFile(path string, fn func() error) error {
lock, err := acquireLock(path)
if err != nil {
return err
}
defer lock.Close()
return fn()
}
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, base+".*.tmp")
if err != nil {
return fmt.Errorf("create tmp: %w", err)
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod tmp: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write tmp: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync tmp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close tmp: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("rename tmp: %w", err)
}
if err := syncDir(dir); err != nil {
return err
}
return nil
}
func acquireLock(path string) (*LockedFile, error) {
lockPath := path + ".lock"
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("open lock: %w", err)
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
_ = file.Close()
return nil, fmt.Errorf("lock file: %w", err)
}
return &LockedFile{file: file}, nil
}
func (l *LockedFile) Close() error {
if l == nil || l.file == nil {
return nil
}
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
closeErr := l.file.Close()
if unlockErr != nil {
return fmt.Errorf("unlock file: %w", unlockErr)
}
if closeErr != nil {
return fmt.Errorf("close lock: %w", closeErr)
}
return nil
}
func syncDir(dir string) error {
f, err := os.Open(dir)
if err != nil {
return fmt.Errorf("open dir: %w", err)
}
defer f.Close()
if err := f.Sync(); err != nil {
return fmt.Errorf("sync dir: %w", err)
}
return nil
}

View File

@ -1,5 +1,5 @@
// Package opencode reads and writes OpenCode config files, including the // Package opencode reads and writes OpenCode config files, including the
// `provider.olpx` sync path. Files may be JSON or JSONC; we preserve the // `provider.ocswitch` sync path. Files may be JSON or JSONC; we preserve the
// detected extension on write. // detected extension on write.
package opencode package opencode
@ -13,9 +13,15 @@ import (
"reflect" "reflect"
"sort" "sort"
"github.com/Apale7/opencode-provider-switch/internal/fileutil"
"github.com/tidwall/jsonc" "github.com/tidwall/jsonc"
) )
const (
ProviderKey = "ocswitch"
ProviderName = "OpenCode Provider Switch CLI"
)
// ConfigFileCandidates is the precedence order inside the global config dir. // ConfigFileCandidates is the precedence order inside the global config dir.
var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"} var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"}
@ -72,25 +78,25 @@ func Load(path string) (Raw, error) {
return out, nil return out, nil
} }
// Save writes provider.olpx back to path. Existing files are normalized to plain // Save writes provider.ocswitch back to path. Existing files are normalized to plain
// JSON and only the provider.olpx subtree is patched so unrelated key order stays // JSON and only the provider.ocswitch subtree is patched so unrelated key order stays
// stable. New files are still written from the full Raw object. Writes are // stable. New files are still written from the full Raw object. Writes are
// atomic. // atomic.
func Save(path string, raw Raw) error { func Save(path string, raw Raw) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err) return fmt.Errorf("mkdir: %w", err)
} }
data, err := renderSaveData(path, raw) return fileutil.WithLockedFile(path, func() error {
if err != nil { data, err := renderSaveData(path, raw)
return err if err != nil {
} return err
tmp := path + ".tmp" }
if err := os.WriteFile(tmp, data, 0o600); err != nil { if err := fileutil.AtomicWriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write tmp: %w", err) return err
} }
return os.Rename(tmp, path) return nil
})
} }
func renderSaveData(path string, raw Raw) ([]byte, error) { func renderSaveData(path string, raw Raw) ([]byte, error) {
original, err := os.ReadFile(path) original, err := os.ReadFile(path)
if err != nil { if err != nil {
@ -102,7 +108,7 @@ func renderSaveData(path string, raw Raw) ([]byte, error) {
if len(bytes.TrimSpace(original)) == 0 { if len(bytes.TrimSpace(original)) == 0 {
return marshalRaw(raw) return marshalRaw(raw)
} }
patched, err := patchProviderOLPXDocument(original, raw) patched, err := patchProviderDocument(original, raw)
if err != nil { if err != nil {
return nil, fmt.Errorf("patch %s: %w", path, err) return nil, fmt.Errorf("patch %s: %w", path, err)
} }
@ -122,8 +128,8 @@ func marshalRaw(raw Raw) ([]byte, error) {
return data, nil return data, nil
} }
func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) { func patchProviderDocument(original []byte, raw Raw) ([]byte, error) {
olpxRaw, ok := olpxProviderValue(raw) providerValue, ok := syncedProviderValue(raw)
if !ok { if !ok {
return marshalRaw(raw) return marshalRaw(raw)
} }
@ -144,7 +150,7 @@ func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
} }
provider := root.findMember("provider") provider := root.findMember("provider")
if provider == nil { if provider == nil {
return insertObjectMember(normalized, root, "provider", map[string]any{"olpx": olpxRaw}) return insertObjectMember(normalized, root, "provider", map[string]any{ProviderKey: providerValue})
} }
if normalized[provider.valueStart] != '{' { if normalized[provider.valueStart] != '{' {
return nil, fmt.Errorf("top-level provider must be an object") return nil, fmt.Errorf("top-level provider must be an object")
@ -153,23 +159,23 @@ func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
olpx := providerObj.findMember("olpx") providerEntry := providerObj.findMember(ProviderKey)
if olpx == nil { if providerEntry == nil {
return insertObjectMember(normalized, providerObj, "olpx", olpxRaw) return insertObjectMember(normalized, providerObj, ProviderKey, providerValue)
} }
return replaceObjectMember(normalized, *olpx, olpxRaw) return replaceObjectMember(normalized, *providerEntry, providerValue)
} }
func olpxProviderValue(raw Raw) (map[string]any, bool) { func syncedProviderValue(raw Raw) (map[string]any, bool) {
providerRaw, _ := raw["provider"].(map[string]any) providerRaw, _ := raw["provider"].(map[string]any)
if providerRaw == nil { if providerRaw == nil {
return nil, false return nil, false
} }
olpxRaw, _ := providerRaw["olpx"].(map[string]any) providerEntry, _ := providerRaw[ProviderKey].(map[string]any)
if olpxRaw == nil { if providerEntry == nil {
return nil, false return nil, false
} }
return olpxRaw, true return providerEntry, true
} }
type objectSpan struct { type objectSpan struct {
@ -410,13 +416,13 @@ func lineIndent(data []byte, pos int) string {
return string(data[lineStart:lineEnd]) return string(data[lineStart:lineEnd])
} }
// EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given // EnsureOcswitchProvider updates (or creates) the provider.ocswitch entry with the given
// local base URL, local api key and alias set. Existing keys on provider.olpx // local base URL, local api key and alias set. Existing keys on provider.ocswitch
// are preserved unless they conflict with the sync intent. For model entries, // are preserved unless they conflict with the sync intent. For model entries,
// sync owns only the alias set: same-name model objects are left untouched so // sync owns only the alias set: same-name model objects are left untouched so
// OpenCode-only metadata survives round-trips. Returns true if the file would // OpenCode-only metadata survives round-trips. Returns true if the file would
// actually change. // actually change.
func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool { func EnsureOcswitchProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
changed := false changed := false
if _, ok := raw["$schema"]; !ok { if _, ok := raw["$schema"]; !ok {
raw["$schema"] = "https://opencode.ai/config.json" raw["$schema"] = "https://opencode.ai/config.json"
@ -428,22 +434,22 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
raw["provider"] = provRaw raw["provider"] = provRaw
changed = true changed = true
} }
olpxRaw, _ := provRaw["olpx"].(map[string]any) providerEntry, _ := provRaw[ProviderKey].(map[string]any)
if olpxRaw == nil { if providerEntry == nil {
olpxRaw = map[string]any{} providerEntry = map[string]any{}
provRaw["olpx"] = olpxRaw provRaw[ProviderKey] = providerEntry
changed = true changed = true
} }
if setIfDiff(olpxRaw, "npm", "@ai-sdk/openai") { if setIfDiff(providerEntry, "npm", "@ai-sdk/openai") {
changed = true changed = true
} }
if setIfDiff(olpxRaw, "name", "OpenCode LocalProxy CLI") { if setIfDiff(providerEntry, "name", ProviderName) {
changed = true changed = true
} }
opts, _ := olpxRaw["options"].(map[string]any) opts, _ := providerEntry["options"].(map[string]any)
if opts == nil { if opts == nil {
opts = map[string]any{} opts = map[string]any{}
olpxRaw["options"] = opts providerEntry["options"] = opts
changed = true changed = true
} }
if setIfDiff(opts, "baseURL", baseURL) { if setIfDiff(opts, "baseURL", baseURL) {
@ -457,7 +463,7 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
} }
// Build models map from alias list. Preserve any existing per-model objects // Build models map from alias list. Preserve any existing per-model objects
// verbatim if the alias key matches; drop aliases removed locally. // verbatim if the alias key matches; drop aliases removed locally.
existingModels, _ := olpxRaw["models"].(map[string]any) existingModels, _ := providerEntry["models"].(map[string]any)
newModels := map[string]any{} newModels := map[string]any{}
aliasSet := map[string]bool{} aliasSet := map[string]bool{}
for _, a := range aliases { for _, a := range aliases {
@ -476,43 +482,43 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
} }
} }
if !mapsEqualShallow(existingModels, newModels) { if !mapsEqualShallow(existingModels, newModels) {
olpxRaw["models"] = newModels providerEntry["models"] = newModels
} }
return changed return changed
} }
// ValidateOLPXProvider checks that provider.olpx matches the MVP sync contract. // ValidateOcswitchProvider checks that provider.ocswitch matches the MVP sync contract.
func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) error { func ValidateOcswitchProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
provRaw, _ := raw["provider"].(map[string]any) provRaw, _ := raw["provider"].(map[string]any)
if provRaw == nil { if provRaw == nil {
return fmt.Errorf("missing provider object") return fmt.Errorf("missing provider object")
} }
olpxRaw, _ := provRaw["olpx"].(map[string]any) providerEntry, _ := provRaw[ProviderKey].(map[string]any)
if olpxRaw == nil { if providerEntry == nil {
return fmt.Errorf("missing provider.olpx") return fmt.Errorf("missing provider.%s", ProviderKey)
} }
if npm, _ := olpxRaw["npm"].(string); npm != "@ai-sdk/openai" { if npm, _ := providerEntry["npm"].(string); npm != "@ai-sdk/openai" {
return fmt.Errorf("provider.olpx.npm must be @ai-sdk/openai") return fmt.Errorf("provider.%s.npm must be @ai-sdk/openai", ProviderKey)
} }
if name, _ := olpxRaw["name"].(string); name != "OpenCode LocalProxy CLI" { if name, _ := providerEntry["name"].(string); name != ProviderName {
return fmt.Errorf("provider.olpx.name must be OpenCode LocalProxy CLI") return fmt.Errorf("provider.%s.name must be %s", ProviderKey, ProviderName)
} }
opts, _ := olpxRaw["options"].(map[string]any) opts, _ := providerEntry["options"].(map[string]any)
if opts == nil { if opts == nil {
return fmt.Errorf("provider.olpx.options missing") return fmt.Errorf("provider.%s.options missing", ProviderKey)
} }
if got, _ := opts["baseURL"].(string); got != baseURL { if got, _ := opts["baseURL"].(string); got != baseURL {
return fmt.Errorf("provider.olpx.options.baseURL mismatch") return fmt.Errorf("provider.%s.options.baseURL mismatch", ProviderKey)
} }
if got, _ := opts["apiKey"].(string); got != apiKey { if got, _ := opts["apiKey"].(string); got != apiKey {
return fmt.Errorf("provider.olpx.options.apiKey mismatch") return fmt.Errorf("provider.%s.options.apiKey mismatch", ProviderKey)
} }
if got, ok := opts["setCacheKey"].(bool); !ok || !got { if got, ok := opts["setCacheKey"].(bool); !ok || !got {
return fmt.Errorf("provider.olpx.options.setCacheKey must be true") return fmt.Errorf("provider.%s.options.setCacheKey must be true", ProviderKey)
} }
models, _ := olpxRaw["models"].(map[string]any) models, _ := providerEntry["models"].(map[string]any)
if models == nil { if models == nil {
return fmt.Errorf("provider.olpx.models missing") return fmt.Errorf("provider.%s.models missing", ProviderKey)
} }
expected := append([]string(nil), aliases...) expected := append([]string(nil), aliases...)
sort.Strings(expected) sort.Strings(expected)
@ -520,17 +526,17 @@ func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) err
for alias, v := range models { for alias, v := range models {
modelCfg, _ := v.(map[string]any) modelCfg, _ := v.(map[string]any)
if modelCfg == nil { if modelCfg == nil {
return fmt.Errorf("provider.olpx.models.%s must be an object", alias) return fmt.Errorf("provider.%s.models.%s must be an object", ProviderKey, alias)
} }
actual = append(actual, alias) actual = append(actual, alias)
} }
sort.Strings(actual) sort.Strings(actual)
if len(actual) != len(expected) { if len(actual) != len(expected) {
return fmt.Errorf("provider.olpx.models alias set mismatch") return fmt.Errorf("provider.%s.models alias set mismatch", ProviderKey)
} }
for i := range actual { for i := range actual {
if actual[i] != expected[i] { if actual[i] != expected[i] {
return fmt.Errorf("provider.olpx.models alias set mismatch") return fmt.Errorf("provider.%s.models alias set mismatch", ProviderKey)
} }
} }
return nil return nil
@ -556,13 +562,13 @@ type ImportableProvider struct {
} }
// ImportCustomProviders scans raw for @ai-sdk/openai custom providers that // ImportCustomProviders scans raw for @ai-sdk/openai custom providers that
// declare baseURL and an apiKey-compatible setting. The `olpx` id itself is // declare baseURL and an apiKey-compatible setting. The synced provider id itself is
// skipped so sync output is not re-imported. // skipped so sync output is not re-imported.
func ImportCustomProviders(raw Raw) []ImportableProvider { func ImportCustomProviders(raw Raw) []ImportableProvider {
out := []ImportableProvider{} out := []ImportableProvider{}
provRaw, _ := raw["provider"].(map[string]any) provRaw, _ := raw["provider"].(map[string]any)
for id, v := range provRaw { for id, v := range provRaw {
if id == "olpx" { if id == ProviderKey {
continue continue
} }
m, ok := v.(map[string]any) m, ok := v.(map[string]any)
@ -594,6 +600,7 @@ func ImportCustomProviders(raw Raw) []ImportableProvider {
for k := range models { for k := range models {
ip.Models = append(ip.Models, k) ip.Models = append(ip.Models, k)
} }
sort.Strings(ip.Models)
} }
out = append(out, ip) out = append(out, ip)
} }

View File

@ -64,35 +64,35 @@ func TestResolveGlobalConfigPathPrecedence(t *testing.T) {
} }
} }
func TestValidateOLPXProvider(t *testing.T) { func TestValidateOcswitchProvider(t *testing.T) {
raw := Raw{} raw := Raw{}
aliases := []string{"gpt-5.4", "gpt-5.4-mini"} aliases := []string{"gpt-5.4", "gpt-5.4-mini"}
baseURL := "http://127.0.0.1:9982/v1" baseURL := "http://127.0.0.1:9982/v1"
apiKey := "olpx-local" apiKey := "ocswitch-local"
EnsureOLPXProvider(raw, baseURL, apiKey, aliases) EnsureOcswitchProvider(raw, baseURL, apiKey, aliases)
providerRaw, _ := raw["provider"].(map[string]any) providerRaw, _ := raw["provider"].(map[string]any)
olpxRaw, _ := providerRaw["olpx"].(map[string]any) providerEntry, _ := providerRaw[ProviderKey].(map[string]any)
opts, _ := olpxRaw["options"].(map[string]any) opts, _ := providerEntry["options"].(map[string]any)
if got, ok := opts["setCacheKey"].(bool); !ok || !got { if got, ok := opts["setCacheKey"].(bool); !ok || !got {
t.Fatalf("provider.olpx.options.setCacheKey = %#v, want true", opts["setCacheKey"]) t.Fatalf("provider.%s.options.setCacheKey = %#v, want true", ProviderKey, opts["setCacheKey"])
} }
if err := ValidateOLPXProvider(raw, baseURL, apiKey, aliases); err != nil { if err := ValidateOcswitchProvider(raw, baseURL, apiKey, aliases); err != nil {
t.Fatalf("ValidateOLPXProvider() unexpected error: %v", err) t.Fatalf("ValidateOcswitchProvider() unexpected error: %v", err)
} }
} }
func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) { func TestEnsureOcswitchProviderPreservesExistingModelMetadata(t *testing.T) {
raw := Raw{ raw := Raw{
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": map[string]any{ "provider": map[string]any{
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{ "models": map[string]any{
@ -116,14 +116,14 @@ func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) {
}, },
} }
changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) changed := EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
if changed { if changed {
t.Fatal("EnsureOLPXProvider() reported change for unchanged same-name alias") t.Fatal("EnsureOcswitchProvider() reported change for unchanged same-name alias")
} }
providerRaw := raw["provider"].(map[string]any) providerRaw := raw["provider"].(map[string]any)
olpxRaw := providerRaw["olpx"].(map[string]any) providerEntry := providerRaw[ProviderKey].(map[string]any)
models := olpxRaw["models"].(map[string]any) models := providerEntry["models"].(map[string]any)
model := models["gpt-5.4"].(map[string]any) model := models["gpt-5.4"].(map[string]any)
if got := model["name"]; got != "custom-display-name" { if got := model["name"]; got != "custom-display-name" {
t.Fatalf("model name = %#v, want custom-display-name preserved", got) t.Fatalf("model name = %#v, want custom-display-name preserved", got)
@ -142,16 +142,16 @@ func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) {
} }
} }
func TestEnsureOLPXProviderDoesNotPanicOnComparableMetadata(t *testing.T) { func TestEnsureOcswitchProviderDoesNotPanicOnComparableMetadata(t *testing.T) {
raw := Raw{ raw := Raw{
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": map[string]any{ "provider": map[string]any{
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{ "models": map[string]any{
@ -169,25 +169,25 @@ func TestEnsureOLPXProviderDoesNotPanicOnComparableMetadata(t *testing.T) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
t.Fatalf("EnsureOLPXProvider() panicked with slice metadata: %v", r) t.Fatalf("EnsureOcswitchProvider() panicked with slice metadata: %v", r)
} }
}() }()
changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) changed := EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
if changed { if changed {
t.Fatal("EnsureOLPXProvider() reported change for unchanged alias metadata with slices") t.Fatal("EnsureOcswitchProvider() reported change for unchanged alias metadata with slices")
} }
} }
func TestValidateOLPXProviderAllowsCustomModelMetadata(t *testing.T) { func TestValidateOcswitchProviderAllowsCustomModelMetadata(t *testing.T) {
raw := Raw{ raw := Raw{
"provider": map[string]any{ "provider": map[string]any{
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{ "models": map[string]any{
@ -200,62 +200,62 @@ func TestValidateOLPXProviderAllowsCustomModelMetadata(t *testing.T) {
}, },
} }
if err := ValidateOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil { if err := ValidateOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOLPXProvider() unexpected error for custom metadata: %v", err) t.Fatalf("ValidateOcswitchProvider() unexpected error for custom metadata: %v", err)
} }
} }
func TestRenderSaveDataReplacesExistingProviderOLPXOnly(t *testing.T) { func TestPatchProviderDocumentReplacesExistingProviderOnly(t *testing.T) {
raw := Raw{ raw := Raw{
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"model": "olpx/gpt-5.4", "model": "ocswitch/gpt-5.4",
"provider": map[string]any{ "provider": map[string]any{
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"}, "anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
}, },
"openai": map[string]any{"npm": "@ai-sdk/openai"}, "openai": map[string]any{"npm": "@ai-sdk/openai"},
}, },
"small_model": "olpx/gpt-5.4-mini", "small_model": "ocswitch/gpt-5.4-mini",
} }
original := []byte("{\n \"model\": \"olpx/old\",\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"olpx\": {\n \"npm\": \"old\",\n \"options\": {\"baseURL\": \"http://old/v1\"},\n \"models\": {\"old\": {\"name\": \"old\"}}\n },\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"small_model\": \"olpx/old-mini\"\n}\n") original := []byte("{\n \"model\": \"ocswitch/old\",\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"ocswitch\": {\n \"npm\": \"old\",\n \"options\": {\"baseURL\": \"http://old/v1\"},\n \"models\": {\"old\": {\"name\": \"old\"}}\n },\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"small_model\": \"ocswitch/old-mini\"\n}\n")
got, err := patchProviderOLPXDocument(original, raw) got, err := patchProviderDocument(original, raw)
if err != nil { if err != nil {
t.Fatalf("patchProviderOLPXDocument() error: %v", err) t.Fatalf("patchProviderDocument() error: %v", err)
} }
assertValidJSON(t, got) assertValidJSON(t, got)
assertStringOrder(t, string(got), []string{`"model"`, `"provider"`, `"small_model"`}) assertStringOrder(t, string(got), []string{`"model"`, `"provider"`, `"small_model"`})
assertStringOrder(t, string(got), []string{`"anthropic"`, `"olpx"`, `"openai"`}) assertStringOrder(t, string(got), []string{`"anthropic"`, `"ocswitch"`, `"openai"`})
if strings.Contains(string(got), `"npm": "old"`) { if strings.Contains(string(got), `"npm": "old"`) {
t.Fatalf("old provider.olpx content still present: %s", string(got)) t.Fatalf("old provider.ocswitch content still present: %s", string(got))
} }
var saved Raw var saved Raw
if err := json.Unmarshal(got, &saved); err != nil { if err := json.Unmarshal(got, &saved); err != nil {
t.Fatalf("unmarshal patched json: %v", err) t.Fatalf("unmarshal patched json: %v", err)
} }
if err := ValidateOLPXProvider(saved, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil { if err := ValidateOcswitchProvider(saved, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOLPXProvider(saved) error: %v", err) t.Fatalf("ValidateOcswitchProvider(saved) error: %v", err)
} }
} }
func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(t *testing.T) { func TestPatchProviderDocumentInsertsOcswitchWithoutReorderingProviderKeys(t *testing.T) {
raw := Raw{ raw := Raw{
"provider": map[string]any{ "provider": map[string]any{
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"}, "anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
@ -263,52 +263,52 @@ func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(t *testing.T) {
"openai": map[string]any{"npm": "@ai-sdk/openai"}, "openai": map[string]any{"npm": "@ai-sdk/openai"},
}, },
} }
original := []byte("{\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"model\": \"olpx/gpt-5.4\"\n}\n") original := []byte("{\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"model\": \"ocswitch/gpt-5.4\"\n}\n")
got, err := patchProviderOLPXDocument(original, raw) got, err := patchProviderDocument(original, raw)
if err != nil { if err != nil {
t.Fatalf("patchProviderOLPXDocument() error: %v", err) t.Fatalf("patchProviderDocument() error: %v", err)
} }
assertValidJSON(t, got) assertValidJSON(t, got)
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"olpx"`}) assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"ocswitch"`})
} }
func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) { func TestPatchProviderDocumentInsertsProviderAtTopLevelEnd(t *testing.T) {
raw := Raw{ raw := Raw{
"model": "olpx/gpt-5.4", "model": "ocswitch/gpt-5.4",
"provider": map[string]any{ "provider": map[string]any{
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
}, },
}, },
"small_model": "olpx/gpt-5.4-mini", "small_model": "ocswitch/gpt-5.4-mini",
} }
original := []byte("{\n \"model\": \"olpx/gpt-5.4\",\n \"small_model\": \"olpx/gpt-5.4-mini\"\n}\n") original := []byte("{\n \"model\": \"ocswitch/gpt-5.4\",\n \"small_model\": \"ocswitch/gpt-5.4-mini\"\n}\n")
got, err := patchProviderOLPXDocument(original, raw) got, err := patchProviderDocument(original, raw)
if err != nil { if err != nil {
t.Fatalf("patchProviderOLPXDocument() error: %v", err) t.Fatalf("patchProviderDocument() error: %v", err)
} }
assertValidJSON(t, got) assertValidJSON(t, got)
assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`}) assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`})
} }
func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) { func TestPatchProviderDocumentAcceptsJSONCAndProducesValidJSON(t *testing.T) {
raw := Raw{ raw := Raw{
"provider": map[string]any{ "provider": map[string]any{
"olpx": map[string]any{ ProviderKey: map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
@ -317,9 +317,9 @@ func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
} }
original := []byte("{\n // comment\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"},\n },\n}\n") original := []byte("{\n // comment\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"},\n },\n}\n")
got, err := patchProviderOLPXDocument(original, raw) got, err := patchProviderDocument(original, raw)
if err != nil { if err != nil {
t.Fatalf("patchProviderOLPXDocument() error: %v", err) t.Fatalf("patchProviderDocument() error: %v", err)
} }
assertValidJSON(t, got) assertValidJSON(t, got)
if bytes.Contains(got, []byte("// comment")) { if bytes.Contains(got, []byte("// comment")) {
@ -327,54 +327,103 @@ func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
} }
} }
func TestRenderSaveDataRejectsInvalidJSONC(t *testing.T) { func TestImportCustomProvidersAllowsEmptyAPIKey(t *testing.T) {
raw := Raw{ raw := Raw{
"provider": map[string]any{ "provider": map[string]any{
"olpx": map[string]any{ "openai-empty": map[string]any{
"npm": "@ai-sdk/openai", "npm": "@ai-sdk/openai",
"name": "OpenCode LocalProxy CLI", "name": "Empty Key",
"options": map[string]any{
"baseURL": "https://example.com/v1",
},
},
},
}
imports := ImportCustomProviders(raw)
if len(imports) != 1 {
t.Fatalf("len(imports) = %d, want 1", len(imports))
}
if imports[0].ID != "openai-empty" {
t.Fatalf("id = %q, want openai-empty", imports[0].ID)
}
if imports[0].APIKey != "" {
t.Fatalf("api key = %q, want empty", imports[0].APIKey)
}
}
func TestImportCustomProvidersSortsModels(t *testing.T) {
raw := Raw{
"provider": map[string]any{
"p1": map[string]any{
"npm": "@ai-sdk/openai",
"options": map[string]any{
"baseURL": "https://example.com/v1",
},
"models": map[string]any{
"z-model": map[string]any{},
"a-model": map[string]any{},
},
},
},
}
imports := ImportCustomProviders(raw)
if len(imports) != 1 {
t.Fatalf("len(imports) = %d, want 1", len(imports))
}
if got := strings.Join(imports[0].Models, ","); got != "a-model,z-model" {
t.Fatalf("Models = %q", got)
}
}
func TestPatchProviderDocumentRejectsInvalidJSONC(t *testing.T) {
raw := Raw{
"provider": map[string]any{
ProviderKey: map[string]any{
"npm": "@ai-sdk/openai",
"name": ProviderName,
"options": map[string]any{ "options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1", "baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "olpx-local", "apiKey": "ocswitch-local",
"setCacheKey": true, "setCacheKey": true,
}, },
}, },
}, },
} }
if _, err := patchProviderOLPXDocument([]byte(`{"provider": {`), raw); err == nil { if _, err := patchProviderDocument([]byte(`{"provider": {`), raw); err == nil {
t.Fatal("expected invalid json/jsonc error") t.Fatal("expected invalid json/jsonc error")
} }
} }
func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) { func TestPatchProviderDocumentRejectsNonObjectProvider(t *testing.T) {
raw := Raw{} raw := Raw{}
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
if _, err := patchProviderOLPXDocument([]byte(`{"provider":"bad"}`), raw); err == nil { if _, err := patchProviderDocument([]byte(`{"provider":"bad"}`), raw); err == nil {
t.Fatal("expected provider object error") t.Fatal("expected provider object error")
} }
} }
func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) { func TestPatchProviderDocumentRejectsNonObjectTopLevel(t *testing.T) {
raw := Raw{} raw := Raw{}
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
if _, err := patchProviderOLPXDocument([]byte(`[]`), raw); err == nil { if _, err := patchProviderDocument([]byte(`[]`), raw); err == nil {
t.Fatal("expected top-level object error") t.Fatal("expected top-level object error")
} }
if _, err := patchProviderOLPXDocument([]byte("{} trailing"), raw); err == nil { if _, err := patchProviderDocument([]byte(`{} trailing`), raw); err == nil {
t.Fatal("expected single top-level object error") t.Fatal("expected single top-level object error")
} }
} }
func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) { func TestSaveWritesValidJSONToDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "opencode.jsonc") path := filepath.Join(t.TempDir(), "opencode.jsonc")
if err := os.WriteFile(path, []byte("{\n \"model\": \"olpx/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil { if err := os.WriteFile(path, []byte("{\n \"model\": \"ocswitch/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil {
t.Fatalf("write seed config: %v", err) t.Fatalf("write seed config: %v", err)
} }
raw := Raw{} raw := Raw{}
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
if err := Save(path, raw); err != nil { if err := Save(path, raw); err != nil {
t.Fatalf("Save() error: %v", err) t.Fatalf("Save() error: %v", err)
@ -388,14 +437,14 @@ func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Load(saved) error: %v", err) t.Fatalf("Load(saved) error: %v", err)
} }
if err := ValidateOLPXProvider(loaded, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil { if err := ValidateOcswitchProvider(loaded, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOLPXProvider(loaded) error: %v", err) t.Fatalf("ValidateOcswitchProvider(loaded) error: %v", err)
} }
} }
func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) { func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
path := filepath.Join(t.TempDir(), "opencode.jsonc") path := filepath.Join(t.TempDir(), "opencode.jsonc")
seed := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"provider\": {\n \"olpx\": {\n \"npm\": \"@ai-sdk/openai\",\n \"name\": \"OpenCode LocalProxy CLI\",\n \"options\": {\n \"baseURL\": \"http://127.0.0.1:9982/v1\",\n \"apiKey\": \"olpx-local\",\n \"setCacheKey\": true\n },\n \"models\": {\n \"gpt-5.4\": {\n \"name\": \"custom-display-name\",\n \"limit\": {\n \"context\": 272000,\n \"output\": 128000\n },\n \"options\": {\n \"serviceTier\": \"priority\"\n }\n }\n }\n }\n }\n}\n") seed := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"provider\": {\n \"ocswitch\": {\n \"npm\": \"@ai-sdk/openai\",\n \"name\": \"OpenCode Provider Switch CLI\",\n \"options\": {\n \"baseURL\": \"http://127.0.0.1:9982/v1\",\n \"apiKey\": \"ocswitch-local\",\n \"setCacheKey\": true\n },\n \"models\": {\n \"gpt-5.4\": {\n \"name\": \"custom-display-name\",\n \"limit\": {\n \"context\": 272000,\n \"output\": 128000\n },\n \"options\": {\n \"serviceTier\": \"priority\"\n }\n }\n }\n }\n }\n}\n")
if err := os.WriteFile(path, seed, 0o600); err != nil { if err := os.WriteFile(path, seed, 0o600); err != nil {
t.Fatalf("write seed config: %v", err) t.Fatalf("write seed config: %v", err)
} }
@ -404,9 +453,9 @@ func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Load(seed) error: %v", err) t.Fatalf("Load(seed) error: %v", err)
} }
changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) changed := EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
if changed { if changed {
t.Fatal("EnsureOLPXProvider() reported change for preserved same-name alias metadata") t.Fatal("EnsureOcswitchProvider() reported change for preserved same-name alias metadata")
} }
if err := Save(path, raw); err != nil { if err := Save(path, raw); err != nil {
t.Fatalf("Save() error: %v", err) t.Fatalf("Save() error: %v", err)
@ -417,8 +466,8 @@ func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
t.Fatalf("Load(saved) error: %v", err) t.Fatalf("Load(saved) error: %v", err)
} }
providerRaw := loaded["provider"].(map[string]any) providerRaw := loaded["provider"].(map[string]any)
olpxRaw := providerRaw["olpx"].(map[string]any) providerEntry := providerRaw[ProviderKey].(map[string]any)
models := olpxRaw["models"].(map[string]any) models := providerEntry["models"].(map[string]any)
model := models["gpt-5.4"].(map[string]any) model := models["gpt-5.4"].(map[string]any)
if got := model["name"]; got != "custom-display-name" { if got := model["name"]; got != "custom-display-name" {
t.Fatalf("saved model name = %#v, want custom-display-name preserved", got) t.Fatalf("saved model name = %#v, want custom-display-name preserved", got)

View File

@ -0,0 +1,65 @@
package opencode
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
type ModelListResponse struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
func FetchProviderModels(baseURL, apiKey string, headers map[string]string) ([]string, error) {
url := strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/models"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
for key, value := range headers {
key = strings.TrimSpace(key)
if key == "" {
continue
}
req.Header.Set(key, value)
}
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var body struct {
Error any `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Error != nil {
return nil, fmt.Errorf("request %s: unexpected status %s: %v", url, resp.Status, body.Error)
}
return nil, fmt.Errorf("request %s: unexpected status %s", url, resp.Status)
}
var payload ModelListResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode %s: %w", url, err)
}
models := make([]string, 0, len(payload.Data))
seen := map[string]bool{}
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if id == "" || seen[id] {
continue
}
seen[id] = true
models = append(models, id)
}
sort.Strings(models)
return models, nil
}

View File

@ -0,0 +1,99 @@
package opencode
import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestFetchProviderModels(t *testing.T) {
t.Parallel()
t.Run("success", func(t *testing.T) {
t.Parallel()
var auth string
var custom string
var method string
var path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
path = r.URL.Path
auth = r.Header.Get("Authorization")
custom = r.Header.Get("X-Test")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"},{"id":"gpt-4.1"},{"id":"gpt-4o"}]}`))
}))
defer srv.Close()
models, err := FetchProviderModels(srv.URL+"/v1", "sk-test", map[string]string{"X-Test": "1"})
if err != nil {
t.Fatalf("FetchProviderModels() error = %v", err)
}
if auth != "Bearer sk-test" {
t.Fatalf("Authorization = %q", auth)
}
if method != http.MethodGet {
t.Fatalf("Method = %q", method)
}
if path != "/v1/models" {
t.Fatalf("Path = %q", path)
}
if custom != "1" {
t.Fatalf("X-Test = %q", custom)
}
want := []string{"gpt-4.1", "gpt-4o"}
if !reflect.DeepEqual(models, want) {
t.Fatalf("models = %#v, want %#v", models, want)
}
})
t.Run("status error", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"error":"bad key"}`)
}))
defer srv.Close()
_, err := FetchProviderModels(srv.URL+"/v1", "", nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "bad key") {
t.Fatalf("error = %q", err.Error())
}
})
t.Run("empty data", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":[]}`)
}))
defer srv.Close()
models, err := FetchProviderModels(srv.URL+"/v1", "", nil)
if err != nil {
t.Fatalf("FetchProviderModels() error = %v", err)
}
if len(models) != 0 {
t.Fatalf("models = %#v, want empty", models)
}
})
t.Run("invalid json", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{bad`)
}))
defer srv.Close()
_, err := FetchProviderModels(srv.URL+"/v1", "", nil)
if err == nil {
t.Fatal("expected error")
}
})
}

View File

@ -1,6 +1,3 @@
// Package proxy implements the local `/v1/responses` HTTP server that resolves
// olpx aliases and forwards requests to upstream providers with deterministic
// pre-first-byte failover.
package proxy package proxy
import ( import (
@ -17,22 +14,30 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
var firstByteTimeout = 15 * time.Second 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"`
} }
type upstreamFailure struct {
status int
header http.Header
body []byte
}
type openAIError struct { type openAIError struct {
Message string `json:"message"` Message string `json:"message"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
} }
// Server is the local olpx HTTP proxy. // Server is the local ocswitch HTTP proxy.
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
client *http.Client client *http.Client
@ -52,17 +57,16 @@ func New(cfg *config.Config) *Server {
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: firstByteTimeout, ResponseHeaderTimeout: firstByteTimeout,
// no response buffering so streams flow through immediately DisableCompression: false,
DisableCompression: false, ForceAttemptHTTP2: true,
ForceAttemptHTTP2: true,
} }
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
client: &http.Client{ client: &http.Client{
Transport: transport, Transport: transport,
Timeout: 0, // streaming, no overall timeout Timeout: 0,
}, },
logger: log.New(log.Writer(), "[olpx] ", log.LstdFlags|log.Lmicroseconds), logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
} }
} }
@ -80,10 +84,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: requestReadTimeout,
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }() go func() { errCh <- srv.ListenAndServe() }()
s.logger.Printf("listening on http://%s", addr)
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)
@ -109,7 +113,7 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
data = append(data, map[string]any{ data = append(data, map[string]any{
"id": aliasName, "id": aliasName,
"object": "model", "object": "model",
"owned_by": "olpx", "owned_by": config.AppName,
}) })
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -147,7 +151,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
} }
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 50<<20)) body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 50<<20))
if err != nil { if err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "read body: "+err.Error()) status, msg := requestReadError(err)
writeOpenAIError(w, status, "invalid_request_error", msg)
return return
} }
var payload map[string]any var payload map[string]any
@ -182,6 +187,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
} }
failoverCount := 0 failoverCount := 0
var lastRetryable *upstreamFailure
for attempt, t := range targets { for attempt, t := range targets {
p := s.cfg.FindProvider(t.Provider) p := s.cfg.FindProvider(t.Provider)
if p == nil || !p.IsEnabled() { if p == nil || !p.IsEnabled() {
@ -190,7 +196,6 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
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)
// rewrite payload.model for this upstream
cloned := cloneMap(payload) cloned := cloneMap(payload)
cloned["model"] = t.Model cloned["model"] = t.Model
newBody, err := json.Marshal(cloned) newBody, err := json.Marshal(cloned)
@ -200,24 +205,34 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
return return
} }
ok, retryable, upstreamErr := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount) ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount)
if ok { if ok {
return return
} }
if !retryable { if !retryable {
// final — response was either already committed or unrecoverable
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)
return return
} }
if failure != nil {
lastRetryable = failure
}
s.logger.Printf("req=%d alias=%s attempt=%d retryable: %v", reqID, aliasName, attempt+1, upstreamErr) s.logger.Printf("req=%d alias=%s attempt=%d retryable: %v", reqID, aliasName, attempt+1, upstreamErr)
failoverCount++ failoverCount++
} }
// exhausted all targets with retryable failures if lastRetryable != nil {
copyResponseHeaders(w.Header(), lastRetryable.header)
w.WriteHeader(lastRetryable.status)
if len(lastRetryable.body) > 0 {
_, _ = w.Write(lastRetryable.body)
}
return
}
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). // tryOnce proxies one attempt. Returns (ok, retryable, err, failure).
// ok=true means successful response fully/partially written to client. // ok=true means successful response fully/partially written to client.
// 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(
@ -230,11 +245,14 @@ func (s *Server) tryOnce(
aliasName string, aliasName string,
attempt int, attempt int,
failoverCount int, failoverCount int,
) (ok bool, retryable bool, err error) { ) (ok bool, retryable bool, err error, failure *upstreamFailure) {
ctx, cancel := context.WithCancel(ctx)
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) return 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")
@ -250,46 +268,41 @@ func (s *Server) tryOnce(
startedAt := time.Now() startedAt := time.Now()
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) return false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 500 || resp.StatusCode == 429 { if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
// drain up to small cap for logging context failure = captureRetryableFailure(resp)
peek, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(peek), 200))
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
// non-retryable: forward status + body to client
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)
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) _, _ = io.Copy(w, resp.Body)
return true, false, fmt.Errorf("upstream %d", resp.StatusCode) return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
} }
remaining := firstByteTimeout - time.Since(startedAt) remaining := firstByteTimeout - time.Since(startedAt)
if remaining <= 0 { if remaining <= 0 {
_ = resp.Body.Close() return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout)
} }
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) {
_ = resp.Body.Close() return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout)
} }
if errors.Is(firstErr, io.EOF) { if errors.Is(firstErr, io.EOF) {
if len(firstChunk) == 0 { if len(firstChunk) == 0 {
firstChunk = nil firstChunk = nil
} }
} else { } else {
return false, true, fmt.Errorf("upstream first read: %w", firstErr) return false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
} }
} }
// 2xx: start streaming pass-through. From this point no failover is allowed.
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)
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)
@ -297,7 +310,7 @@ 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 return true, false, werr, nil
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@ -305,10 +318,10 @@ func (s *Server) tryOnce(
} }
buf := make([]byte, 16<<10) buf := make([]byte, 16<<10)
for { for {
n, rerr := resp.Body.Read(buf) n, rerr := readChunkWithTimeout(resp.Body, buf, streamIdleTimeout)
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 return true, false, werr, nil
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@ -316,15 +329,15 @@ 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 return true, false, nil, nil
} }
// mid-stream error — already committed, cannot failover return true, false, rerr, nil
return true, false, rerr
} }
} }
} }
var errFirstByteTimeout = errors.New("first byte timeout") var errFirstByteTimeout = errors.New("first byte timeout")
var errStreamIdleTimeout = errors.New("stream idle timeout")
func readFirstChunk(r io.Reader, timeout time.Duration) ([]byte, error) { func readFirstChunk(r io.Reader, timeout time.Duration) ([]byte, error) {
type result struct { type result struct {
@ -350,6 +363,44 @@ func readFirstChunk(r io.Reader, timeout time.Duration) ([]byte, error) {
} }
} }
func readChunkWithTimeout(r io.Reader, buf []byte, timeout time.Duration) (int, error) {
type result struct {
n int
err error
}
ch := make(chan result, 1)
go func() {
n, err := r.Read(buf)
ch <- result{n: n, err: err}
}()
select {
case res := <-ch:
return res.n, res.err
case <-time.After(timeout):
return 0, errStreamIdleTimeout
}
}
func captureRetryableFailure(resp *http.Response) *upstreamFailure {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 32<<10))
return &upstreamFailure{
status: resp.StatusCode,
header: cloneHeaderSubset(resp.Header, "Content-Type", "Retry-After"),
body: body,
}
}
func cloneHeaderSubset(src http.Header, names ...string) http.Header {
dst := make(http.Header)
for _, name := range names {
ck := http.CanonicalHeaderKey(name)
for _, v := range src.Values(ck) {
dst.Add(ck, v)
}
}
return dst
}
func writeOpenAIError(w http.ResponseWriter, status int, code, message string) { func writeOpenAIError(w http.ResponseWriter, status int, code, message string) {
h := w.Header() h := w.Header()
h.Set("Content-Type", "application/json") h.Set("Content-Type", "application/json")
@ -370,8 +421,20 @@ func errorTypeForStatus(status int) string {
return "invalid_request_error" return "invalid_request_error"
} }
func requestReadError(err error) (int, string) {
var netErr net.Error
switch {
case errors.As(err, &netErr) && netErr.Timeout():
return http.StatusRequestTimeout, "request body read timeout"
case strings.Contains(strings.ToLower(err.Error()), "timeout"):
return http.StatusRequestTimeout, "request body read timeout"
default:
return http.StatusBadRequest, "read body: " + err.Error()
}
}
func normalizeAliasName(model string) string { func normalizeAliasName(model string) string {
const prefix = "olpx/" prefix := config.AppName + "/"
if strings.HasPrefix(model, prefix) { if strings.HasPrefix(model, prefix) {
trimmed := strings.TrimPrefix(model, prefix) trimmed := strings.TrimPrefix(model, prefix)
if trimmed != "" { if trimmed != "" {
@ -381,17 +444,16 @@ func normalizeAliasName(model string) string {
return model return model
} }
// writeDebugHeaders sets the X-OLPX-* debug headers before WriteHeader. // writeDebugHeaders sets the X-OCSWITCH-* debug headers before WriteHeader.
func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) { func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) {
h := w.Header() h := w.Header()
h.Set("X-OLPX-Alias", alias) h.Set("X-OCSWITCH-Alias", alias)
h.Set("X-OLPX-Provider", provider) h.Set("X-OCSWITCH-Provider", provider)
h.Set("X-OLPX-Remote-Model", remoteModel) h.Set("X-OCSWITCH-Remote-Model", remoteModel)
h.Set("X-OLPX-Attempt", fmt.Sprintf("%d", attempt)) h.Set("X-OCSWITCH-Attempt", fmt.Sprintf("%d", attempt))
h.Set("X-OLPX-Failover-Count", fmt.Sprintf("%d", failoverCount)) h.Set("X-OCSWITCH-Failover-Count", fmt.Sprintf("%d", failoverCount))
} }
// hopByHopHeaders lists headers that must not be forwarded per RFC 7230.
var hopByHopHeaders = map[string]bool{ var hopByHopHeaders = map[string]bool{
"Connection": true, "Connection": true,
"Proxy-Connection": true, "Proxy-Connection": true,
@ -404,16 +466,15 @@ var hopByHopHeaders = map[string]bool{
"Upgrade": true, "Upgrade": true,
} }
// copyForwardHeaders copies safe request headers from client to upstream,
// dropping Authorization (the upstream key replaces it) and hop-by-hop headers.
func copyForwardHeaders(dst, src http.Header) { func copyForwardHeaders(dst, src http.Header) {
connectionHeaders := connectionDeclaredHeaders(src)
for k, vs := range src { for k, vs := range src {
ck := http.CanonicalHeaderKey(k) ck := http.CanonicalHeaderKey(k)
if hopByHopHeaders[ck] { if hopByHopHeaders[ck] || connectionHeaders[ck] {
continue continue
} }
switch ck { switch ck {
case "Authorization", "X-Api-Key", "Host", "Content-Length": case "Authorization", "X-Api-Key", "Host", "Content-Length", "Transfer-Encoding", "Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "Via":
continue continue
} }
for _, v := range vs { for _, v := range vs {
@ -422,7 +483,19 @@ func copyForwardHeaders(dst, src http.Header) {
} }
} }
// copyResponseHeaders copies upstream response headers into client response. func connectionDeclaredHeaders(src http.Header) map[string]bool {
declared := map[string]bool{}
for _, raw := range src.Values("Connection") {
for _, part := range strings.Split(raw, ",") {
name := http.CanonicalHeaderKey(strings.TrimSpace(part))
if name != "" {
declared[name] = true
}
}
}
return declared
}
func copyResponseHeaders(dst, src http.Header) { func copyResponseHeaders(dst, src http.Header) {
for k, vs := range src { for k, vs := range src {
ck := http.CanonicalHeaderKey(k) ck := http.CanonicalHeaderKey(k)
@ -430,7 +503,6 @@ func copyResponseHeaders(dst, src http.Header) {
continue continue
} }
if ck == "Content-Length" { if ck == "Content-Length" {
// We may transform nothing, but streaming responses often omit this.
continue continue
} }
for _, v := range vs { for _, v := range vs {
@ -439,7 +511,6 @@ func copyResponseHeaders(dst, src http.Header) {
} }
} }
// cloneMap performs a shallow copy of a top-level map.
func cloneMap(m map[string]any) map[string]any { func cloneMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m)) out := make(map[string]any, len(m))
for k, v := range m { for k, v := range m {
@ -448,10 +519,9 @@ func cloneMap(m map[string]any) map[string]any {
return out return out
} }
// truncate returns s truncated to at most n bytes (best-effort).
func truncate(s string, n int) string { func truncate(s string, n int) string {
if len(s) <= n { if len(s) <= n {
return s return s
} }
return s[:n] + "…" return s[:n]
} }

View File

@ -11,17 +11,17 @@ import (
"testing" "testing"
"time" "time"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) { func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
t.Parallel() t.Parallel()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -64,7 +64,7 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
defer second.Close() defer second.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"}, {ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"},
{ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"}, {ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"},
@ -76,8 +76,8 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
}}, }},
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"olpx/gpt-5.4","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"ocswitch/gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream") req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -96,14 +96,14 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
if secondSeenModel != "up-2" { if secondSeenModel != "up-2" {
t.Fatalf("second upstream model = %q, want up-2", secondSeenModel) t.Fatalf("second upstream model = %q, want up-2", secondSeenModel)
} }
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" { if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "2" {
t.Fatalf("X-OLPX-Attempt = %q, want 2", got) t.Fatalf("X-OCSWITCH-Attempt = %q, want 2", got)
} }
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" { if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "1" {
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got) t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 1", got)
} }
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
t.Fatalf("X-OLPX-Provider = %q, want p2", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
} }
} }
@ -124,7 +124,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
defer second.Close() defer second.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1"}, {ID: "p1", BaseURL: first.URL + "/v1"},
{ID: "p2", BaseURL: second.URL + "/v1"}, {ID: "p2", BaseURL: second.URL + "/v1"},
@ -137,7 +137,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -149,14 +149,119 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
if calledSecond { if calledSecond {
t.Fatal("second upstream should not be called for 400 response") t.Fatal("second upstream should not be called for 400 response")
} }
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p1" {
t.Fatalf("X-OLPX-Provider = %q, want p1", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p1", got)
} }
if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` { if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` {
t.Fatalf("body = %q", body) t.Fatalf("body = %q", body)
} }
} }
func TestHandleResponsesReturnsLastRetryableFailure(t *testing.T) {
t.Parallel()
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "7")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":{"message":"rate limited"}}`))
}))
defer first.Close()
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"message":"upstream unavailable"}}`))
}))
defer second.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1"},
{ID: "p2", BaseURL: second.URL + "/v1"},
},
Aliases: []config.Alias{{
Alias: "gpt-5.4",
Enabled: true,
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}, {Provider: "p2", Model: "up-2", 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")
rr := httptest.NewRecorder()
srv.handleResponses(rr, req)
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadGateway)
}
if got := rr.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", got)
}
if got := rr.Header().Get("Retry-After"); got != "" {
t.Fatalf("Retry-After = %q, want empty from last failure", got)
}
if body := rr.Body.String(); body != `{"error":{"message":"upstream unavailable"}}` {
t.Fatalf("body = %q", body)
}
}
func TestCopyForwardHeadersDropsDynamicConnectionHeaders(t *testing.T) {
t.Parallel()
src := http.Header{}
src.Set("Connection", "X-Trace-Id, Keep-Alive")
src.Set("X-Trace-Id", "abc")
src.Set("Keep-Alive", "timeout=5")
src.Set("OpenAI-Beta", "assistants=v2")
src.Set("X-Forwarded-For", "1.2.3.4")
dst := http.Header{}
copyForwardHeaders(dst, src)
if got := dst.Get("X-Trace-Id"); got != "" {
t.Fatalf("X-Trace-Id = %q, want empty", got)
}
if got := dst.Get("Keep-Alive"); got != "" {
t.Fatalf("Keep-Alive = %q, want empty", got)
}
if got := dst.Get("X-Forwarded-For"); got != "" {
t.Fatalf("X-Forwarded-For = %q, want empty", got)
}
if got := dst.Get("OpenAI-Beta"); got != "assistants=v2" {
t.Fatalf("OpenAI-Beta = %q, want assistants=v2", got)
}
}
func TestReadChunkWithTimeout(t *testing.T) {
t.Parallel()
t.Run("returns data", func(t *testing.T) {
t.Parallel()
buf := make([]byte, 8)
n, err := readChunkWithTimeout(bytes.NewBufferString("abc"), buf, 50*time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 3 || string(buf[:n]) != "abc" {
t.Fatalf("read = %d %q, want 3 abc", n, string(buf[:n]))
}
})
t.Run("times out", func(t *testing.T) {
t.Parallel()
buf := make([]byte, 8)
n, err := readChunkWithTimeout(blockingReader{}, buf, 20*time.Millisecond)
if !errors.Is(err, errStreamIdleTimeout) {
t.Fatalf("err = %v, want errStreamIdleTimeout", err)
}
if n != 0 {
t.Fatalf("n = %d, want 0", n)
}
})
}
func TestHandleResponsesSkipsDisabledProviders(t *testing.T) { func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
t.Parallel() t.Parallel()
@ -181,7 +286,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
defer enabled.Close() defer enabled.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true}, {ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true},
{ID: "p2", BaseURL: enabled.URL + "/v1"}, {ID: "p2", BaseURL: enabled.URL + "/v1"},
@ -194,7 +299,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream") req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -210,14 +315,14 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
if seenModel != "up-2" { if seenModel != "up-2" {
t.Fatalf("enabled upstream model = %q, want up-2", seenModel) t.Fatalf("enabled upstream model = %q, want up-2", seenModel)
} }
if got := rr.Header().Get("X-OLPX-Attempt"); got != "1" { if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "1" {
t.Fatalf("X-OLPX-Attempt = %q, want 1", got) t.Fatalf("X-OCSWITCH-Attempt = %q, want 1", got)
} }
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "0" { if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "0" {
t.Fatalf("X-OLPX-Failover-Count = %q, want 0", got) t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 0", got)
} }
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
t.Fatalf("X-OLPX-Provider = %q, want p2", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
} }
if body := rr.Body.String(); body != "data: ok\n\n" { if body := rr.Body.String(); body != "data: ok\n\n" {
t.Fatalf("body = %q, want SSE payload", body) t.Fatalf("body = %q, want SSE payload", body)
@ -228,7 +333,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
t.Parallel() t.Parallel()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: "https://p1.example.com/v1"}, {ID: "p1", BaseURL: "https://p1.example.com/v1"},
{ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true}, {ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true},
@ -241,7 +346,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
srv.handleModels(rr, req) srv.handleModels(rr, req)
@ -260,6 +365,18 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
} }
} }
func TestRequestReadErrorTimeout(t *testing.T) {
t.Parallel()
status, message := requestReadError(timeoutErr{})
if status != http.StatusRequestTimeout {
t.Fatalf("status = %d, want %d", status, http.StatusRequestTimeout)
}
if message != "request body read timeout" {
t.Fatalf("message = %q", message)
}
}
func TestReadFirstChunk(t *testing.T) { func TestReadFirstChunk(t *testing.T) {
t.Parallel() t.Parallel()
@ -309,8 +426,12 @@ func TestReadFirstChunk(t *testing.T) {
} }
type blockingReader struct{} type blockingReader struct{}
type dataEOFReader struct{} type dataEOFReader struct{}
type timeoutErr struct{}
func (timeoutErr) Error() string { return "i/o timeout" }
func (timeoutErr) Timeout() bool { return true }
func (timeoutErr) Temporary() bool { return false }
func (blockingReader) Read(p []byte) (int, error) { func (blockingReader) Read(p []byte) (int, error) {
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)

24
main_wails.go Normal file
View File

@ -0,0 +1,24 @@
//go:build desktop_wails
package main
import (
"flag"
"fmt"
"os"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/desktop"
)
var version = "dev"
func main() {
configPath := flag.String("config", "", fmt.Sprintf("path to %s config.json (default: %s)", config.AppName, config.DefaultPath()))
flag.Parse()
if err := desktop.RunWails(*configPath, version); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

12
wails.json Normal file
View File

@ -0,0 +1,12 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "ocswitch-desktop",
"outputfilename": "ocswitch-desktop",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "Apale"
}
}