Compare commits
10 Commits
b5776e4e1f
...
cc3359f773
| Author | SHA1 | Date | |
|---|---|---|---|
| cc3359f773 | |||
| 29d0cabbd1 | |||
| 8cf1a504d1 | |||
| 09d0c1f9c6 | |||
| d6ab8f24da | |||
| 8e5b247ca1 | |||
| 51f030fb8c | |||
| db95b8545a | |||
| cf3dcec399 | |||
| 594ecadfdd |
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@
|
||||
/dist/
|
||||
*.tmp
|
||||
output
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
167
.trellis/tasks/04-17-deep-code-review-ocswitch/review.md
Normal file
167
.trellis/tasks/04-17-deep-code-review-ocswitch/review.md
Normal 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` 非法值校验
|
||||
@ -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 ./...`
|
||||
71
.trellis/tasks/04-17-deep-code-review-ocswitch/task.json
Normal file
71
.trellis/tasks/04-17-deep-code-review-ocswitch/task.json
Normal 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 ./..."
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
# OLPX Forwarding Log Viewer Design
|
||||
# OCSWITCH Forwarding Log Viewer Design
|
||||
|
||||
## 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:
|
||||
|
||||
@ -23,18 +23,18 @@ The design is intentionally scoped to the current codebase:
|
||||
|
||||
### 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`
|
||||
- deterministic ordered failover
|
||||
- retry on transport errors, `429`, and `5xx`
|
||||
- no mid-stream failover after downstream response starts
|
||||
- debug response headers:
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
### What is missing
|
||||
|
||||
@ -74,12 +74,12 @@ Reason:
|
||||
|
||||
- it fits the current Go-only codebase
|
||||
- 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
|
||||
|
||||
## 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
|
||||
- `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
|
||||
|
||||
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
|
||||
- proposed log path: `~/.config/olpx/request-log.jsonl`
|
||||
- config path today: `~/.config/ocswitch/config.json` by default
|
||||
- proposed log path: `~/.config/ocswitch/request-log.jsonl`
|
||||
|
||||
Reason:
|
||||
|
||||
@ -156,7 +156,7 @@ Each finalized request log entry should contain at least the following fields:
|
||||
"duration_ms": 1881,
|
||||
"protocol": "openai-responses",
|
||||
"alias": "gpt-5.4",
|
||||
"raw_model": "olpx/gpt-5.4",
|
||||
"raw_model": "ocswitch/gpt-5.4",
|
||||
"stream": true,
|
||||
"final_status": 200,
|
||||
"final_provider": "p2",
|
||||
@ -195,7 +195,7 @@ Each finalized request log entry should contain at least the following fields:
|
||||
|
||||
### 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
|
||||
- `final_provider`: provider that produced the downstream response visible to the client
|
||||
- `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
|
||||
- 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
|
||||
|
||||
@ -438,7 +438,7 @@ Suggested existing files to extend:
|
||||
|
||||
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
|
||||
3. each row clearly shows final provider and final upstream model
|
||||
4. each row clearly shows whether failover happened
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "forwarding-log-viewer-gui",
|
||||
"name": "forwarding-log-viewer-gui",
|
||||
"title": "Design forwarding log viewer GUI for olpx",
|
||||
"description": "Define a minimal local web UI and request logging design for olpx forwarding operations, including provider/model visibility, token usage, and failover display.",
|
||||
"title": "Design forwarding log viewer GUI for ocswitch",
|
||||
"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",
|
||||
"dev_type": "docs",
|
||||
"scope": "observability",
|
||||
@ -54,9 +54,25 @@
|
||||
"internal/cli/serve.go",
|
||||
"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": {
|
||||
"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": [
|
||||
"openai-responses"
|
||||
],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "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": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
|
||||
715
.trellis/tasks/04-18-desktop-shell-gui/prd.md
Normal file
715
.trellis/tasks/04-18-desktop-shell-gui/prd.md
Normal 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.
|
||||
106
.trellis/tasks/04-18-desktop-shell-gui/task.json
Normal file
106
.trellis/tasks/04-18-desktop-shell-gui/task.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,25 @@
|
||||
# OLPX Agent-First CLI Help System
|
||||
# OCSWITCH Agent-First CLI Help System
|
||||
|
||||
## 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
|
||||
AI agent trying to configure the tool end-to-end with high reliability.
|
||||
|
||||
This PRD defines a narrow product change:
|
||||
|
||||
- every user-facing `olpx` command must provide `Long` help
|
||||
- every user-facing `olpx` command must provide `Example` help
|
||||
- every user-facing `ocswitch` command must provide `Long` help
|
||||
- every user-facing `ocswitch` command must provide `Example` help
|
||||
- help content must be written for **AI agent execution reliability first**
|
||||
- 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,
|
||||
planning, execution, and recovery during configuration.
|
||||
|
||||
## 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
|
||||
inspection, or README-only instructions.
|
||||
|
||||
@ -27,7 +27,7 @@ inspection, or README-only instructions.
|
||||
|
||||
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."
|
||||
|
||||
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
|
||||
- create aliases and bind ordered targets
|
||||
- validate config via `olpx doctor`
|
||||
- sync aliases into OpenCode via `olpx opencode sync`
|
||||
- run proxy via `olpx serve`
|
||||
- validate config via `ocswitch doctor`
|
||||
- sync aliases into OpenCode via `ocswitch opencode sync`
|
||||
- run proxy via `ocswitch serve`
|
||||
|
||||
But the command tree does not yet expose the full operational contract through
|
||||
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
|
||||
- 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.
|
||||
|
||||
## 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:
|
||||
|
||||
- `olpx`
|
||||
- `olpx serve`
|
||||
- `olpx doctor`
|
||||
- `olpx provider`
|
||||
- `olpx provider add`
|
||||
- `olpx provider list`
|
||||
- `olpx provider remove`
|
||||
- `olpx provider import-opencode`
|
||||
- `olpx alias`
|
||||
- `olpx alias add`
|
||||
- `olpx alias list`
|
||||
- `olpx alias bind`
|
||||
- `olpx alias unbind`
|
||||
- `olpx alias remove`
|
||||
- `olpx opencode`
|
||||
- `olpx opencode sync`
|
||||
- `ocswitch`
|
||||
- `ocswitch serve`
|
||||
- `ocswitch doctor`
|
||||
- `ocswitch provider`
|
||||
- `ocswitch provider add`
|
||||
- `ocswitch provider list`
|
||||
- `ocswitch provider remove`
|
||||
- `ocswitch provider import-opencode`
|
||||
- `ocswitch alias`
|
||||
- `ocswitch alias add`
|
||||
- `ocswitch alias list`
|
||||
- `ocswitch alias bind`
|
||||
- `ocswitch alias unbind`
|
||||
- `ocswitch alias remove`
|
||||
- `ocswitch opencode`
|
||||
- `ocswitch opencode sync`
|
||||
|
||||
### Also In Scope
|
||||
|
||||
@ -169,7 +169,7 @@ Examples must avoid:
|
||||
|
||||
### 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.
|
||||
|
||||
Their help must answer:
|
||||
@ -184,8 +184,8 @@ Whenever a command writes local config or OpenCode config, help must say so.
|
||||
|
||||
Examples:
|
||||
|
||||
- provider commands write `olpx` config
|
||||
- alias commands write `olpx` config
|
||||
- provider commands write `ocswitch` config
|
||||
- alias commands write `ocswitch` config
|
||||
- `opencode sync` writes the target OpenCode config unless `--dry-run`
|
||||
- `doctor` validates statically and does not call upstream providers
|
||||
- `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:
|
||||
|
||||
- default `olpx` config path via `--config`
|
||||
- default `ocswitch` config path via `--config`
|
||||
- default OpenCode sync target resolution order
|
||||
- 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
|
||||
|
||||
### Root Command: `olpx`
|
||||
### Root Command: `ocswitch`
|
||||
|
||||
`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
|
||||
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:
|
||||
|
||||
@ -268,7 +268,7 @@ used by agents to confirm imported or saved provider IDs before binding aliases.
|
||||
|
||||
`Long` should explain:
|
||||
|
||||
- removes provider from `olpx` config
|
||||
- removes provider from `ocswitch` config
|
||||
- does not automatically clean alias references
|
||||
- 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`
|
||||
|
||||
`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:
|
||||
|
||||
@ -366,7 +366,7 @@ OpenCode as `olpx/<alias>`, and that target order defines failover priority.
|
||||
`Long` should explain:
|
||||
|
||||
- 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
|
||||
|
||||
`Example` should include:
|
||||
@ -391,7 +391,7 @@ OpenCode as `olpx/<alias>`, and that target order defines failover priority.
|
||||
### Group Command: `opencode`
|
||||
|
||||
`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:
|
||||
|
||||
@ -407,7 +407,7 @@ contract.
|
||||
|
||||
- exact write target rules
|
||||
- what fields are mutated and not mutated
|
||||
- that aliases become `provider.olpx.models`
|
||||
- that aliases become `provider.ocswitch.models`
|
||||
- meaning of `--dry-run`
|
||||
- recommended call order around `doctor`
|
||||
|
||||
@ -481,7 +481,7 @@ scenarios without README-only dependency.
|
||||
|
||||
### Scenario 1: Configure from scratch
|
||||
|
||||
1. inspect `olpx --help`
|
||||
1. inspect `ocswitch --help`
|
||||
2. add one or more providers
|
||||
3. add alias
|
||||
4. bind targets in order
|
||||
@ -491,7 +491,7 @@ scenarios without README-only dependency.
|
||||
|
||||
### 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
|
||||
3. list providers
|
||||
4. create aliases and bindings
|
||||
@ -527,9 +527,9 @@ README should not be the only place where critical command semantics live.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
3. Help text for mutating commands explicitly states what files/config are
|
||||
written.
|
||||
@ -593,14 +593,14 @@ duplication becomes genuinely harmful.
|
||||
|
||||
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
|
||||
- structured `--help-format json` for agent-native consumption
|
||||
- README agent prompt template that mirrors the help contract
|
||||
|
||||
## 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
|
||||
tool. `Long` and `Example` are therefore not documentation polish; they are part
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"id": "04-17-agent-first-cli-help-prd",
|
||||
"name": "04-17-agent-first-cli-help-prd",
|
||||
"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",
|
||||
"dev_type": "docs",
|
||||
"scope": null,
|
||||
@ -57,7 +57,7 @@
|
||||
"internal/cli/serve.go",
|
||||
"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": {
|
||||
"primaryAudience": "ai-agent",
|
||||
"priorityRule": "agent-first-manual-second",
|
||||
|
||||
@ -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": {}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
# OLPX MVP Redesign
|
||||
# OCSWITCH MVP Redesign
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
@ -56,13 +56,13 @@ User wants:
|
||||
|
||||
## 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
|
||||
|
||||
```text
|
||||
OpenCode
|
||||
-> custom provider `olpx` (@ai-sdk/openai)
|
||||
-> custom provider `ocswitch` (@ai-sdk/openai)
|
||||
-> http://127.0.0.1:9982/v1/responses
|
||||
-> alias resolver
|
||||
-> failover engine
|
||||
@ -77,10 +77,10 @@ OpenCode
|
||||
|
||||
MVP should expose exactly one local provider to OpenCode:
|
||||
|
||||
- provider id: `olpx`
|
||||
- provider id: `ocswitch`
|
||||
- npm package: `@ai-sdk/openai`
|
||||
- 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:
|
||||
|
||||
@ -88,12 +88,12 @@ Conceptual OpenCode config shape:
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"olpx": {
|
||||
"ocswitch": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OPS",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local"
|
||||
"apiKey": "ocswitch-local"
|
||||
},
|
||||
"models": {
|
||||
"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 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:
|
||||
|
||||
- keep alias list in `olpx`
|
||||
- sync alias list into OpenCode `provider.olpx.models`
|
||||
- make sure `provider.olpx` is valid enough to appear as a connected runtime provider
|
||||
- let OpenCode surface `olpx/<alias>` in `/models` and `/model`
|
||||
- keep alias list in `ocswitch`
|
||||
- sync alias list into OpenCode `provider.ocswitch.models`
|
||||
- make sure `provider.ocswitch` is valid enough to appear as a connected runtime provider
|
||||
- let OpenCode surface `ocswitch/<alias>` in `/models` and `/model`
|
||||
|
||||
### Important Scope Rule
|
||||
|
||||
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
|
||||
- optionally sync alias entries into `provider.olpx.models`
|
||||
- ensure `provider.ocswitch` exists or is updated
|
||||
- optionally sync alias entries into `provider.ocswitch.models`
|
||||
- optionally let user set `model` or `small_model` manually
|
||||
|
||||
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:
|
||||
|
||||
- `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
|
||||
- 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`
|
||||
@ -153,11 +153,11 @@ Implication for MVP:
|
||||
|
||||
## 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:
|
||||
|
||||
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
|
||||
|
||||
### Supported Provider Shape In MVP
|
||||
@ -179,7 +179,7 @@ OpenCode source also shows that `auth.json` provides credentials for an existing
|
||||
|
||||
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
|
||||
- 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.
|
||||
2. Every alias must contain at least one enabled target.
|
||||
3. Alias target order is explicit and defines failover priority.
|
||||
4. Alias name must be unique within `olpx`.
|
||||
5. OpenCode should reference alias as `olpx/<alias>`.
|
||||
4. Alias name must be unique within `ocswitch`.
|
||||
5. OpenCode should reference alias as `ocswitch/<alias>`.
|
||||
|
||||
### Example
|
||||
|
||||
@ -234,7 +234,7 @@ Rich capability metadata such as `limit`, `attachment`, `reasoning`, `tool_call`
|
||||
1. Receive request from OpenCode.
|
||||
2. Read and buffer full JSON request body once.
|
||||
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.
|
||||
6. Replace only alias model field with concrete upstream model ID.
|
||||
7. Forward request to highest-priority target.
|
||||
@ -303,13 +303,13 @@ Conservative failover is preferable to surprising failover.
|
||||
|
||||
## 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-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
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
|
||||
|
||||
Use one local `olpx` JSON or JSONC config file for:
|
||||
Use one local `ocswitch` JSON or JSONC config file for:
|
||||
|
||||
- upstream providers
|
||||
- aliases
|
||||
@ -341,19 +341,19 @@ Recommended MVP commands:
|
||||
|
||||
### Core
|
||||
|
||||
- `olpx serve`
|
||||
- `olpx doctor`
|
||||
- `ocswitch serve`
|
||||
- `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:
|
||||
|
||||
- local `olpx` config can be loaded
|
||||
- local `ocswitch` config can be loaded
|
||||
- every alias resolves to at least one enabled target
|
||||
- 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:
|
||||
|
||||
@ -361,35 +361,35 @@ It should not, by default:
|
||||
- consume quota from user providers
|
||||
- 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
|
||||
|
||||
- `olpx provider add`
|
||||
- `olpx provider list`
|
||||
- `olpx provider remove`
|
||||
- `olpx provider import-opencode`
|
||||
- `ocswitch provider add`
|
||||
- `ocswitch provider list`
|
||||
- `ocswitch provider remove`
|
||||
- `ocswitch provider import-opencode`
|
||||
|
||||
### Alias Management
|
||||
|
||||
- `olpx alias add`
|
||||
- `olpx alias list`
|
||||
- `olpx alias bind`
|
||||
- `olpx alias unbind`
|
||||
- `olpx alias remove`
|
||||
- `ocswitch alias add`
|
||||
- `ocswitch alias list`
|
||||
- `ocswitch alias bind`
|
||||
- `ocswitch alias unbind`
|
||||
- `ocswitch alias remove`
|
||||
|
||||
### OpenCode Integration
|
||||
|
||||
- `olpx opencode sync`
|
||||
- `ocswitch opencode sync`
|
||||
|
||||
## `olpx opencode sync` Responsibility
|
||||
## `ocswitch opencode sync` Responsibility
|
||||
|
||||
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`
|
||||
- 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:
|
||||
|
||||
@ -418,15 +418,15 @@ Conclusion:
|
||||
|
||||
- alias exposure inside OpenCode is feasible in MVP
|
||||
- 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
|
||||
|
||||
MVP security posture should stay simple:
|
||||
|
||||
- listen on `127.0.0.1` by default
|
||||
- use static local placeholder API key between OpenCode and `olpx`
|
||||
- store upstream credentials in local `olpx` config
|
||||
- use static local placeholder API key between OpenCode and `ocswitch`
|
||||
- store upstream credentials in local `ocswitch` config
|
||||
- document that local credential storage is sensitive
|
||||
|
||||
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
|
||||
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
|
||||
4. select `olpx/<alias>` in OpenCode without exposing concrete upstream model IDs
|
||||
5. send normal streaming OpenAI Responses traffic through `olpx`
|
||||
3. run `ocswitch opencode sync` and see alias names appear in OpenCode `opencode models` output and `/model` picker
|
||||
4. select `ocswitch/<alias>` in OpenCode without exposing concrete upstream model IDs
|
||||
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
|
||||
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
|
||||
|
||||
- `olpx opencode sync`
|
||||
- `ocswitch opencode sync`
|
||||
- narrow OpenCode provider import
|
||||
- alias list exposure in OpenCode config
|
||||
- connected-provider validation in `olpx doctor`
|
||||
- connected-provider validation in `ocswitch doctor`
|
||||
|
||||
### Phase 3
|
||||
|
||||
@ -469,16 +469,16 @@ MVP is successful if a user can:
|
||||
|
||||
### Phase 4
|
||||
|
||||
- `olpx doctor`
|
||||
- `ocswitch doctor`
|
||||
- debugging headers and logs
|
||||
|
||||
## Finalized MVP Decisions
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# OLPX MVP Design
|
||||
# OCSWITCH MVP Design
|
||||
|
||||
## 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:
|
||||
|
||||
@ -10,7 +10,7 @@ Its job is narrow:
|
||||
- Route requests by protocol, not by provider brand
|
||||
- Retry/fail over across unreliable upstream relay providers
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
`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
|
||||
- API keys and headers
|
||||
@ -74,7 +74,7 @@ This keeps OpenCode config stable even when upstream providers are added, remove
|
||||
```text
|
||||
OpenCode
|
||||
-> local OpenAI-compatible provider config
|
||||
-> olpx proxy (127.0.0.1:9982)
|
||||
-> ocswitch proxy (127.0.0.1:9982)
|
||||
-> protocol router
|
||||
-> alias resolver
|
||||
-> failover engine
|
||||
@ -127,18 +127,18 @@ OpenCode
|
||||
|
||||
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 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
|
||||
|
||||
### 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:
|
||||
|
||||
@ -146,12 +146,12 @@ Generated provider shape should be conceptually like this:
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"olpx-chat": {
|
||||
"ocswitch-chat": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "OLPX Chat",
|
||||
"name": "OCSWITCH Chat",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local"
|
||||
"apiKey": "ocswitch-local"
|
||||
},
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
@ -159,12 +159,12 @@ Generated provider shape should be conceptually like this:
|
||||
}
|
||||
}
|
||||
},
|
||||
"olpx-responses": {
|
||||
"ocswitch-responses": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OLPX Responses",
|
||||
"name": "OCSWITCH Responses",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local"
|
||||
"apiKey": "ocswitch-local"
|
||||
},
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
@ -173,16 +173,16 @@ Generated provider shape should be conceptually like this:
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "olpx-responses/gpt-5.4",
|
||||
"small_model": "olpx-chat/gpt-5.4-mini"
|
||||
"model": "ocswitch-responses/gpt-5.4",
|
||||
"small_model": "ocswitch-chat/gpt-5.4-mini"
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
- document this clearly
|
||||
- make `olpx doctor` detect likely overrides
|
||||
- make `ocswitch doctor` detect likely overrides
|
||||
- support global install first
|
||||
|
||||
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
|
||||
|
||||
`olpx install` should inspect:
|
||||
`ocswitch install` should inspect:
|
||||
|
||||
1. `~/.config/opencode/opencode.json`
|
||||
2. `~/.config/opencode/opencode.jsonc`
|
||||
@ -236,7 +236,7 @@ For each imported provider/model entry from OpenCode:
|
||||
- tool_call
|
||||
- options
|
||||
- variants
|
||||
5. Generate local `olpx-*` providers for OpenCode.
|
||||
5. Generate local `ocswitch-*` providers for OpenCode.
|
||||
|
||||
### Protocol Classification
|
||||
|
||||
@ -246,18 +246,18 @@ Import classification rules for MVP:
|
||||
- `@ai-sdk/openai` -> `openai-responses`
|
||||
- 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
|
||||
|
||||
`olpx` should not keep its own user-editable config file.
|
||||
`ocswitch` should not keep its own user-editable config file.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -368,7 +368,7 @@ Example:
|
||||
- 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`
|
||||
|
||||
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
|
||||
|
||||
@ -386,13 +386,13 @@ Reduce manual provider setup work.
|
||||
|
||||
### 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:
|
||||
|
||||
- `olpx provider models sync <provider>`
|
||||
- `olpx provider models list <provider>`
|
||||
- `olpx alias suggest <provider>`
|
||||
- `ocswitch provider models sync <provider>`
|
||||
- `ocswitch provider models list <provider>`
|
||||
- `ocswitch alias suggest <provider>`
|
||||
|
||||
### 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**
|
||||
|
||||
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
|
||||
|
||||
@ -477,12 +477,12 @@ This is conservative, but predictable.
|
||||
|
||||
For debugging, add response headers when possible:
|
||||
|
||||
- `X-OLPX-Protocol`
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Protocol`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
### Why This Is Acceptable For MVP
|
||||
@ -528,40 +528,40 @@ Proposed command set:
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- `olpx init`
|
||||
- `olpx serve`
|
||||
- `olpx doctor`
|
||||
- `olpx install`
|
||||
- `olpx restore`
|
||||
- `ocswitch init`
|
||||
- `ocswitch serve`
|
||||
- `ocswitch doctor`
|
||||
- `ocswitch install`
|
||||
- `ocswitch restore`
|
||||
|
||||
### Provider Management
|
||||
|
||||
- `olpx provider add`
|
||||
- `olpx provider list`
|
||||
- `olpx provider edit`
|
||||
- `olpx provider remove`
|
||||
- `olpx provider enable`
|
||||
- `olpx provider disable`
|
||||
- `ocswitch provider add`
|
||||
- `ocswitch provider list`
|
||||
- `ocswitch provider edit`
|
||||
- `ocswitch provider remove`
|
||||
- `ocswitch provider enable`
|
||||
- `ocswitch provider disable`
|
||||
|
||||
### Model Discovery
|
||||
|
||||
- `olpx provider models sync <provider>`
|
||||
- `olpx provider models list <provider>`
|
||||
- `ocswitch provider models sync <provider>`
|
||||
- `ocswitch provider models list <provider>`
|
||||
|
||||
### Alias Management
|
||||
|
||||
- `olpx alias add`
|
||||
- `olpx alias list`
|
||||
- `olpx alias bind`
|
||||
- `olpx alias unbind`
|
||||
- `olpx alias enable`
|
||||
- `olpx alias disable`
|
||||
- `olpx alias inspect <alias>`
|
||||
- `ocswitch alias add`
|
||||
- `ocswitch alias list`
|
||||
- `ocswitch alias bind`
|
||||
- `ocswitch alias unbind`
|
||||
- `ocswitch alias enable`
|
||||
- `ocswitch alias disable`
|
||||
- `ocswitch alias inspect <alias>`
|
||||
|
||||
### Diagnostics
|
||||
|
||||
- `olpx logs tail`
|
||||
- `olpx route test --protocol <protocol> --model <alias>`
|
||||
- `ocswitch logs tail`
|
||||
- `ocswitch route test --protocol <protocol> --model <alias>`
|
||||
|
||||
## Recommended Minimal UX
|
||||
|
||||
@ -569,20 +569,20 @@ Prefer explicit CLI over magical automation.
|
||||
|
||||
Good path:
|
||||
|
||||
1. `olpx init`
|
||||
2. `olpx provider add`
|
||||
3. `olpx provider models sync`
|
||||
4. `olpx alias add`
|
||||
5. `olpx alias bind`
|
||||
6. `olpx install`
|
||||
7. `olpx serve`
|
||||
1. `ocswitch init`
|
||||
2. `ocswitch provider add`
|
||||
3. `ocswitch provider models sync`
|
||||
4. `ocswitch alias add`
|
||||
5. `ocswitch alias bind`
|
||||
6. `ocswitch install`
|
||||
7. `ocswitch serve`
|
||||
|
||||
This is easy to explain and easy to debug.
|
||||
|
||||
## Suggested Go Package Layout
|
||||
|
||||
```text
|
||||
cmd/olpx/
|
||||
cmd/ocswitch/
|
||||
internal/cli/
|
||||
internal/db/
|
||||
internal/models/
|
||||
@ -608,12 +608,12 @@ Avoid adding a heavy HTTP framework unless a real need appears.
|
||||
|
||||
## 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:
|
||||
|
||||
- `olpx-chat`
|
||||
- `olpx-responses`
|
||||
- `ocswitch-chat`
|
||||
- `ocswitch-responses`
|
||||
|
||||
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:
|
||||
|
||||
- `olpx` becomes the stable local provider boundary
|
||||
- `ocswitch` becomes the stable local provider boundary
|
||||
- upstream providers should move into SQLite management only
|
||||
|
||||
## WSL / Windows Strategy
|
||||
@ -632,18 +632,18 @@ This requirement is important, but it needs careful wording.
|
||||
|
||||
1. Native Linux/WSL build works.
|
||||
2. Native Windows build works.
|
||||
3. Running OpenCode and `olpx` in the **same environment** is supported.
|
||||
4. `olpx doctor` helps detect config-path and loopback issues.
|
||||
3. Running OpenCode and `ocswitch` in the **same environment** is supported.
|
||||
4. `ocswitch doctor` helps detect config-path and loopback issues.
|
||||
|
||||
### 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.
|
||||
3. Zero-config interop when OpenCode runs on one side and proxy on the other.
|
||||
|
||||
### 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.
|
||||
|
||||
@ -655,7 +655,7 @@ Global config rewrite alone may not capture project-level overrides.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- `olpx doctor`
|
||||
- `ocswitch doctor`
|
||||
- clear docs
|
||||
- possible future `ops install --project`
|
||||
|
||||
@ -704,7 +704,7 @@ MVP is successful if a user can:
|
||||
1. Import existing OpenCode provider setup into `ops`.
|
||||
2. Create or verify aliases for commonly used models.
|
||||
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`.
|
||||
6. Survive common upstream failures by automatic provider failover.
|
||||
|
||||
@ -734,7 +734,7 @@ MVP is successful if a user can:
|
||||
|
||||
- streaming support
|
||||
- logging/diagnostics
|
||||
- `olpx doctor`
|
||||
- `ocswitch doctor`
|
||||
- restore flow
|
||||
|
||||
## Strong Recommendation
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "provider-disable-failover",
|
||||
"name": "provider-disable-failover",
|
||||
"title": "Support disabling providers in olpx",
|
||||
"title": "Support disabling providers in ocswitch",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 5
|
||||
- **Last Active**: 2026-04-17
|
||||
- **Total Sessions**: 12
|
||||
- **Last Active**: 2026-04-18
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~216 | Active |
|
||||
| `journal-1.md` | ~525 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@ -29,9 +29,16 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | 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` |
|
||||
| 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` |
|
||||
| 1 | 2026-04-17 | Finalize OPS MVP review PRD | `eeacdc4`, `00747fa`, `ca0b3c4` | `master` |
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
@ -91,10 +91,10 @@ Implemented the Go-based OPS MVP: local config, provider and alias CLI, OpenCode
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 3: Support disabling providers in olpx
|
||||
## Session 3: Support disabling providers in ocswitch
|
||||
|
||||
**Date**: 2026-04-17
|
||||
**Task**: Support disabling providers in olpx
|
||||
**Task**: Support disabling providers in ocswitch
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
@ -139,7 +139,7 @@ Reviewed current implementation against the archived MVP PRD and classified impl
|
||||
| Category | Result |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
@ -214,3 +214,312 @@ Fixed a panic in opencode sync when preserved model metadata contains slices, an
|
||||
### Next Steps
|
||||
|
||||
- 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
207
README.md
@ -1,13 +1,13 @@
|
||||
# opencode-provider-switch (`olpx`)
|
||||
# opencode-provider-switch (`ocswitch`)
|
||||
|
||||
English README: `README_EN.md`
|
||||
|
||||
`olpx` 是 OpenCode LocalProxy CLI,给 OpenCode 使用的本地代理。
|
||||
`ocswitch` 是 OpenCode Provider Switch CLI,给 OpenCode 使用的本地代理。
|
||||
|
||||
它解决的问题很简单:
|
||||
|
||||
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `olpx/gpt-5.4`
|
||||
- `olpx` 在本地把这个别名映射到多个上游 `provider/model`
|
||||
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `ocswitch/gpt-5.4`
|
||||
- `ocswitch` 在本地把这个别名映射到多个上游 `provider/model`
|
||||
- 按你配置的顺序依次尝试上游
|
||||
- 如果主上游在响应开始前失败,自动切到下一个上游
|
||||
|
||||
@ -16,17 +16,17 @@ English README: `README_EN.md`
|
||||
## 适合什么场景
|
||||
|
||||
- 你有多个 OpenAI 兼容上游
|
||||
- 你不想在 OpenCode 里频繁切换 provider
|
||||
- 你不想在 OpenCode 里频繁切换 provider 或模型入口
|
||||
- 你希望用一个固定别名承接多个备用上游
|
||||
- 你希望失败切换行为是确定的、可预期的
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 本地维护 `olpx` 配置文件:上游 provider、alias、监听地址
|
||||
- 支持手动添加 provider
|
||||
- 本地维护 `ocswitch` 配置文件:上游 provider、alias、监听地址
|
||||
- 支持手动添加 provider,并自动发现其 `/v1/models` 模型列表
|
||||
- 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider
|
||||
- 支持创建 alias,并按顺序绑定多个上游 target
|
||||
- 支持把 alias 同步到 OpenCode 的 `provider.olpx.models`
|
||||
- 支持把 alias 同步到 OpenCode 的 `provider.ocswitch.models`
|
||||
- 支持本地代理 `POST /v1/responses`
|
||||
- 支持流式透传
|
||||
- 支持首字节前失败切换
|
||||
@ -45,30 +45,29 @@ English README: `README_EN.md`
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go build -o olpx ./cmd/olpx
|
||||
go build -o ocswitch ./cmd/ocswitch
|
||||
```
|
||||
|
||||
如果你只想临时运行,也可以直接:
|
||||
|
||||
```bash
|
||||
go run ./cmd/olpx --help
|
||||
go run ./cmd/ocswitch --help
|
||||
```
|
||||
|
||||
## 5 分钟快速上手
|
||||
|
||||
### 1. 添加上游 provider
|
||||
|
||||
`olpx` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。
|
||||
|
||||
`ocswitch` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。默认会自动调用上游 `/v1/models` 拉取模型列表并缓存到本地配置,后续绑定 alias 时可用于校验模型名、减少手写 typo;如果发现失败,只会输出 warning,不会阻止连接信息保存。如果某些 provider 不开放该接口,可以显式加 `--skip-models` 仅保存连接信息。
|
||||
```bash
|
||||
olpx 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 su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx
|
||||
ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy
|
||||
```
|
||||
|
||||
如果某个上游还需要额外请求头,可以重复传 `--header`:
|
||||
|
||||
```bash
|
||||
olpx provider add \
|
||||
ocswitch provider add \
|
||||
--id relay \
|
||||
--base-url https://example.com/v1 \
|
||||
--api-key sk-zzz \
|
||||
@ -76,45 +75,50 @@ olpx provider add \
|
||||
--header "X-Workspace=my-team"
|
||||
```
|
||||
|
||||
如果你之后想把这些额外 header 全部清空,可以显式传:
|
||||
|
||||
```bash
|
||||
ocswitch provider add --id relay --base-url https://example.com/v1 --clear-headers --skip-models
|
||||
```
|
||||
|
||||
查看当前 provider:
|
||||
|
||||
```bash
|
||||
olpx provider list
|
||||
ocswitch provider list
|
||||
```
|
||||
|
||||
### 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
|
||||
olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
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
|
||||
```
|
||||
|
||||
查看当前 alias:
|
||||
|
||||
```bash
|
||||
olpx alias list
|
||||
ocswitch alias list
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- target 的顺序就是失败切换顺序
|
||||
- enabled alias 必须至少有一个可路由 target
|
||||
- `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
||||
- `ocswitch alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
||||
|
||||
### 3. 先做一次静态检查
|
||||
|
||||
```bash
|
||||
olpx doctor
|
||||
ocswitch doctor
|
||||
```
|
||||
|
||||
`olpx doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
|
||||
`ocswitch doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
|
||||
|
||||
它会检查:
|
||||
|
||||
- 本地 `olpx` 配置能不能正常加载
|
||||
- 本地 `ocswitch` 配置能不能正常加载
|
||||
- alias 是否引用了不存在的 provider
|
||||
- enabled alias 是否至少有一个可路由 target
|
||||
- 本地代理监听地址是否合理
|
||||
@ -123,56 +127,57 @@ olpx doctor
|
||||
### 4. 把 alias 同步到 OpenCode
|
||||
|
||||
```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`
|
||||
- 如果都不存在,就创建 `~/.config/opencode/opencode.jsonc`
|
||||
- 默认目标明确只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR`
|
||||
- 只更新 `provider.olpx`
|
||||
- 只更新 `provider.ocswitch`
|
||||
- 不会修改顶层 `model`
|
||||
- 不会修改顶层 `small_model`
|
||||
|
||||
如果你希望顺手把默认模型也切到 `olpx`,需要显式指定:
|
||||
如果你希望顺手把默认模型也切到 `ocswitch`,需要显式指定:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --set-model olpx/gpt-5.4
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4
|
||||
```
|
||||
|
||||
如果你还有小模型 alias,也可以这样:
|
||||
|
||||
```bash
|
||||
olpx opencode sync \
|
||||
--set-model olpx/gpt-5.4 \
|
||||
--set-small-model olpx/gpt-5.4-mini
|
||||
ocswitch opencode sync \
|
||||
--set-model ocswitch/gpt-5.4 \
|
||||
--set-small-model ocswitch/gpt-5.4-mini
|
||||
```
|
||||
|
||||
先预览不写入:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --dry-run
|
||||
ocswitch opencode sync --dry-run
|
||||
```
|
||||
|
||||
写到指定 OpenCode 配置文件:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --target /path/to/opencode.jsonc
|
||||
ocswitch opencode sync --target /path/to/opencode.jsonc
|
||||
```
|
||||
|
||||
### 5. 启动本地代理
|
||||
|
||||
```bash
|
||||
olpx serve
|
||||
ocswitch serve
|
||||
```
|
||||
|
||||
默认监听地址:
|
||||
|
||||
- `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 里使用
|
||||
|
||||
完成 `olpx opencode sync` 后,你应该能在 OpenCode 里看到 `olpx/<alias>`。
|
||||
完成 `ocswitch opencode sync` 后,你应该能在 OpenCode 里看到 `ocswitch/<alias>`。
|
||||
|
||||
例如:
|
||||
|
||||
- `olpx/gpt-5.4`
|
||||
- `ocswitch/gpt-5.4`
|
||||
|
||||
如果你执行了:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --set-model olpx/gpt-5.4
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4
|
||||
```
|
||||
|
||||
那么 OpenCode 默认模型也会直接切到这个 alias。
|
||||
|
||||
## 直接验证本地代理
|
||||
|
||||
如果你想先不走 OpenCode,直接验证 `olpx` 是否能正常代理,可以自己发一个请求:
|
||||
如果你想先不走 OpenCode,直接验证 `ocswitch` 是否能正常代理,可以自己发一个请求:
|
||||
|
||||
```bash
|
||||
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" \
|
||||
-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,可以直接导入:
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode
|
||||
ocswitch provider import-opencode
|
||||
```
|
||||
|
||||
或者指定导入源:
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode --from ./examples/opencode.jsonc
|
||||
ocswitch provider import-opencode --from ./examples/opencode.jsonc
|
||||
```
|
||||
|
||||
支持范围只有这一类:
|
||||
|
||||
- `npm: @ai-sdk/openai`
|
||||
- 有 `options.baseURL`
|
||||
- 有 `options.apiKey`
|
||||
|
||||
- `options.apiKey` 可以为空;当前静态校验不会单独拦截空上游 API Key,这类问题通常会在真实请求上游时暴露
|
||||
注意:
|
||||
|
||||
- 这不是完整迁移工具
|
||||
- 默认导入源也只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR`
|
||||
- 如果你要导入别的 OpenCode 配置文件,请显式传 `--from`
|
||||
- 当前只导入 provider 的基本连接信息
|
||||
- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `olpx provider add --header ...` 补齐
|
||||
- `olpx` 自己不会被反向导入
|
||||
- 导入时仍要求 `baseURL` 满足 `/v1` 约束;不合法的 provider 会被跳过
|
||||
- 如果源 OpenCode 配置已经声明 `models`,导入时会一并保留,但这类导入值默认只作为展示/迁移信息,不会像主动发现到的 catalog 那样做硬校验
|
||||
- 如果你修改了 provider 的连接信息,但本次 discovery 被跳过、失败或返回空列表,旧 catalog 会降级成“不可信元数据”:仍保留在配置里,但不会继续用于强校验
|
||||
- 如果你的 provider 不开放 `/v1/models`,后续可用 `ocswitch provider add --skip-models ...` 仅保存连接信息
|
||||
- `--overwrite` 会更新导入到的基本连接信息,但仍保留本地的 disabled 状态、额外 header,以及仍然可信的 discovered catalog
|
||||
- 如果你的旧配置依赖额外自定义 header,首次导入后仍可能需要自己用 `ocswitch provider add --header ...` 补齐
|
||||
- `ocswitch` 自己不会被反向导入
|
||||
|
||||
覆盖已存在的 provider:
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode --overwrite
|
||||
ocswitch provider import-opencode --overwrite
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
@ -253,65 +261,73 @@ olpx provider import-opencode --overwrite
|
||||
添加或更新 provider:
|
||||
|
||||
```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:
|
||||
|
||||
```bash
|
||||
olpx provider list
|
||||
ocswitch provider list
|
||||
```
|
||||
|
||||
禁用 provider:
|
||||
|
||||
```bash
|
||||
olpx provider disable <id>
|
||||
ocswitch provider disable <id>
|
||||
```
|
||||
|
||||
重新启用 provider:
|
||||
|
||||
```bash
|
||||
olpx provider enable <id>
|
||||
ocswitch provider enable <id>
|
||||
```
|
||||
|
||||
删除 provider:
|
||||
|
||||
```bash
|
||||
olpx provider remove <id>
|
||||
ocswitch provider remove <id>
|
||||
```
|
||||
|
||||
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`olpx doctor` 会报错。
|
||||
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`ocswitch doctor` 会报错。
|
||||
|
||||
### alias
|
||||
|
||||
创建或更新 alias:
|
||||
|
||||
```bash
|
||||
olpx alias add --name <alias>
|
||||
ocswitch alias add --name <alias>
|
||||
```
|
||||
|
||||
给 alias 追加一个 target:
|
||||
|
||||
```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:
|
||||
|
||||
```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:
|
||||
|
||||
```bash
|
||||
olpx alias list
|
||||
ocswitch alias list
|
||||
```
|
||||
|
||||
删除 alias:
|
||||
|
||||
```bash
|
||||
olpx alias remove <alias>
|
||||
ocswitch alias remove <alias>
|
||||
```
|
||||
|
||||
### 其他
|
||||
@ -319,44 +335,46 @@ olpx alias remove <alias>
|
||||
静态检查:
|
||||
|
||||
```bash
|
||||
olpx doctor
|
||||
ocswitch doctor
|
||||
```
|
||||
|
||||
启动代理:
|
||||
|
||||
```bash
|
||||
olpx serve
|
||||
ocswitch serve
|
||||
```
|
||||
|
||||
同步到 OpenCode:
|
||||
|
||||
```bash
|
||||
olpx opencode sync
|
||||
ocswitch opencode sync
|
||||
```
|
||||
|
||||
全局帮助:
|
||||
|
||||
```bash
|
||||
olpx --help
|
||||
olpx provider --help
|
||||
olpx alias --help
|
||||
olpx opencode sync --help
|
||||
ocswitch --help
|
||||
ocswitch provider --help
|
||||
ocswitch alias --help
|
||||
ocswitch opencode sync --help
|
||||
```
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
本地 `olpx` 配置文件默认路径:
|
||||
本地 `ocswitch` 配置文件默认路径:
|
||||
|
||||
- 如果设置了 `OLPX_CONFIG`,优先使用它
|
||||
- 否则使用 `$XDG_CONFIG_HOME/olpx/config.json`
|
||||
- 再否则使用 `~/.config/olpx/config.json`
|
||||
- 如果设置了 `OCSWITCH_CONFIG`,优先使用它
|
||||
- 否则使用 `$XDG_CONFIG_HOME/ocswitch/config.json`
|
||||
- 再否则使用 `~/.config/ocswitch/config.json`
|
||||
|
||||
也可以对每个命令显式指定:
|
||||
|
||||
```bash
|
||||
olpx --config /path/to/config.json doctor
|
||||
ocswitch --config /path/to/config.json doctor
|
||||
```
|
||||
|
||||
命令级行为、默认值、写入范围与副作用,以对应命令的 `--help` 为准;README 主要保留快速上手与背景说明。
|
||||
|
||||
一个最小配置示例:
|
||||
|
||||
```json
|
||||
@ -364,7 +382,7 @@ olpx --config /path/to/config.json doctor
|
||||
"server": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9982,
|
||||
"api_key": "olpx-local"
|
||||
"api_key": "ocswitch-local"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
@ -405,7 +423,7 @@ olpx --config /path/to/config.json doctor
|
||||
|
||||
## 失败切换规则
|
||||
|
||||
`olpx` 的切换规则很保守,也很容易理解。
|
||||
`ocswitch` 的切换规则很保守,也很容易理解。
|
||||
|
||||
会切换到下一个 target 的情况:
|
||||
|
||||
@ -434,13 +452,13 @@ olpx --config /path/to/config.json doctor
|
||||
|
||||
## 调试响应头
|
||||
|
||||
每次成功代理或透传上游错误时,响应里都会附带这些头:
|
||||
当请求已经选定某个上游并开始向客户端返回该次尝试的结果时,响应里会附带这些头:
|
||||
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `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 状态
|
||||
3. alias 是否至少绑定了一个可路由 target
|
||||
4. alias 绑定的 provider 是否都被禁用了
|
||||
5. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件
|
||||
6. 执行一次 `olpx doctor`,看输出里的 `opencode config target`
|
||||
5. OpenCode 当前实际使用的配置文件,是否就是 `ocswitch opencode sync` 写入的那个文件
|
||||
6. 执行一次 `ocswitch doctor`,看输出里的 `opencode config target`
|
||||
|
||||
### 为什么 `olpx doctor` 报 alias 没有可用 target?
|
||||
### 为什么 `ocswitch doctor` 报 alias 没有可用 target?
|
||||
|
||||
因为当前实现要求:只要 alias 是 enabled,就必须至少有一个可路由的 target。
|
||||
|
||||
@ -482,14 +500,15 @@ olpx --config /path/to/config.json doctor
|
||||
|
||||
因为 provider 可能被多个 alias 复用。
|
||||
|
||||
`olpx provider disable` 只会让路由层在 failover 时自动跳过这个 provider,不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。
|
||||
`ocswitch provider disable` 只会让路由层在 failover 时自动跳过这个 provider,不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。
|
||||
|
||||
### 删除 provider 后为什么还有报错?
|
||||
|
||||
因为 alias 里的 target 还是旧引用。需要继续执行:
|
||||
|
||||
```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:
|
||||
|
||||
```text
|
||||
olpx-local
|
||||
ocswitch-local
|
||||
```
|
||||
|
||||
OpenCode 在 `provider.olpx.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
|
||||
OpenCode 在 `provider.ocswitch.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 默认只监听 `127.0.0.1`
|
||||
- 上游凭据保存在本地 `olpx` 配置文件中
|
||||
- 上游凭据保存在本地 `ocswitch` 配置文件中
|
||||
- 本项目当前没有做多用户或远程网络安全保证
|
||||
|
||||
所以请把本地配置文件当成敏感文件处理。
|
||||
|
||||
96
README_EN.md
96
README_EN.md
@ -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
|
||||
stable model alias** routed to **multiple upstream providers** with
|
||||
**deterministic failover**.
|
||||
|
||||
- Expose one custom provider `olpx` to OpenCode.
|
||||
- Configure logical aliases (`olpx/gpt-5.4`, etc.).
|
||||
- Expose one custom provider `ocswitch` to OpenCode.
|
||||
- Configure logical aliases (`ocswitch/gpt-5.4`, etc.).
|
||||
- Each alias has an ordered list of upstream `provider/model` targets.
|
||||
- Providers can be disabled without mutating alias target state.
|
||||
- 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
|
||||
request — no mid-stream splicing.
|
||||
|
||||
@ -18,51 +18,57 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go build -o olpx ./cmd/olpx
|
||||
go build -o ocswitch ./cmd/ocswitch
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. add upstream providers
|
||||
olpx 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-...
|
||||
# 1. add upstream providers (models are discovered from /v1/models by default; warnings do not block saving)
|
||||
ocswitch provider add --id su8 --base-url https://cn2.su8.codes/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
|
||||
olpx alias add --name gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
# 2. create alias and bind targets in priority order (preferred Provider/Model form)
|
||||
ocswitch alias add --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
|
||||
|
||||
# 3. push alias exposure into OpenCode global config
|
||||
olpx opencode sync
|
||||
ocswitch opencode sync
|
||||
|
||||
# optional: temporarily disable one provider without editing alias targets
|
||||
olpx provider disable su8
|
||||
ocswitch provider disable su8
|
||||
|
||||
# 4. run the proxy
|
||||
olpx serve
|
||||
ocswitch serve
|
||||
```
|
||||
|
||||
Inside OpenCode you can now pick `olpx/gpt-5.4`.
|
||||
Inside OpenCode you can now pick `ocswitch/gpt-5.4`.
|
||||
|
||||
### Import providers from an existing OpenCode config
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode # reads global OpenCode config
|
||||
olpx provider import-opencode --from ./examples/opencode.jsonc
|
||||
ocswitch provider import-opencode # reads global OpenCode config
|
||||
ocswitch provider import-opencode --from ./examples/opencode.jsonc
|
||||
```
|
||||
|
||||
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
|
||||
different file.
|
||||
|
||||
Only `@ai-sdk/openai` custom providers with a `baseURL` and `apiKey` are
|
||||
imported. Everything else is out of MVP scope.
|
||||
Only `@ai-sdk/openai` custom providers with a `baseURL` are imported. An empty
|
||||
`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
|
||||
olpx doctor
|
||||
ocswitch doctor
|
||||
```
|
||||
|
||||
Runs structural checks only — never issues real upstream requests.
|
||||
@ -75,14 +81,14 @@ considered routable only when:
|
||||
- the referenced provider exists
|
||||
- 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.
|
||||
|
||||
### Provider state
|
||||
|
||||
```bash
|
||||
olpx provider disable <id>
|
||||
olpx provider enable <id>
|
||||
ocswitch provider disable <id>
|
||||
ocswitch provider enable <id>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- `olpx serve` — run the proxy
|
||||
- `olpx doctor` — validate config
|
||||
- `olpx provider {add,list,enable,disable,remove,import-opencode}`
|
||||
- `olpx alias {add,list,bind,unbind,remove}`
|
||||
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
|
||||
For exact command behavior, defaults, write scope, and side effects, prefer the
|
||||
matching `--help` page. This README is the quick-start narrative, while CLI help
|
||||
is the authoritative local execution contract.
|
||||
|
||||
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
|
||||
|
||||
Every proxied response includes:
|
||||
Responses include these debug headers once a concrete upstream attempt is being returned to the client:
|
||||
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
## Scope
|
||||
|
||||
Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing,
|
||||
latency-based routing, full `/v1/models` provider discovery, full OpenCode
|
||||
config takeover.
|
||||
latency-based routing, and full OpenCode config takeover.
|
||||
See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md`
|
||||
for the authoritative design notes.
|
||||
|
||||
32
cmd/ocswitch-desktop/main_fallback.go
Normal file
32
cmd/ocswitch-desktop/main_fallback.go
Normal 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)
|
||||
}
|
||||
}
|
||||
25
cmd/ocswitch-desktop/main_wails.go
Normal file
25
cmd/ocswitch-desktop/main_wails.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
// Command olpx: local alias + failover proxy for OpenCode.
|
||||
// Command ocswitch: local alias + failover proxy for OpenCode.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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=...".
|
||||
@ -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",
|
||||
"small_model": "codex/GPT-5.4-mini",
|
||||
"provider": {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
{
|
||||
// provider.olpx.models entries may include OpenCode-only metadata.
|
||||
// olpx sync preserves same-name model objects and only manages the alias set.
|
||||
// provider.ocswitch.models entries may include OpenCode-only metadata.
|
||||
// ocswitch sync preserves same-name model objects and only manages the alias set.
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "olpx/gpt-5.4",
|
||||
"small_model": "olpx/gpt-5.4-mini",
|
||||
"model": "ocswitch/gpt-5.4",
|
||||
"small_model": "ocswitch/gpt-5.4-mini",
|
||||
"provider": {
|
||||
"olpx": {
|
||||
"ocswitch": {
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
"name": "gpt-5.4",
|
||||
@ -50,10 +50,10 @@
|
||||
"name": "gpt-5.4-mini"
|
||||
}
|
||||
},
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": "OpenCode Provider Switch CLI",
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"setCacheKey": true
|
||||
}
|
||||
13
frontend/assets.go
Normal file
13
frontend/assets.go
Normal 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
15
frontend/index.html
Normal 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
1846
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
1
frontend/package.json.md5
Executable file
@ -0,0 +1 @@
|
||||
2f0b51396a8b6c44ea214228c533bdbe
|
||||
358
frontend/src/App.tsx
Normal file
358
frontend/src/App.tsx
Normal 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/<alias>"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>small_model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={syncInput.setSmallModel || ''}
|
||||
onChange={(event) => setSyncInput((current) => ({ ...current, setSmallModel: event.target.value }))}
|
||||
placeholder="ocswitch/<alias>"
|
||||
/>
|
||||
</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
99
frontend/src/api.ts
Normal 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
27
frontend/src/env.d.ts
vendored
Normal 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
10
frontend/src/main.tsx
Normal 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
232
frontend/src/styles.css
Normal 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
98
frontend/src/types.ts
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal 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
10
frontend/vite.config.ts
Normal 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
49
frontend/wailsjs/go/desktop/App.d.ts
vendored
Executable 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>;
|
||||
91
frontend/wailsjs/go/desktop/App.js
Executable file
91
frontend/wailsjs/go/desktop/App.js
Executable 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
400
frontend/wailsjs/go/models.ts
Executable 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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
frontend/wailsjs/runtime/package.json
Normal file
24
frontend/wailsjs/runtime/package.json
Normal 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
330
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal 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>;
|
||||
298
frontend/wailsjs/runtime/runtime.js
Normal file
298
frontend/wailsjs/runtime/runtime.js
Normal 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
31
go.mod
@ -1,13 +1,42 @@
|
||||
module github.com/anomalyco/opencode-provider-switch
|
||||
module github.com/Apale7/opencode-provider-switch
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/tidwall/jsonc v0.3.2
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
)
|
||||
|
||||
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/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/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
82
go.sum
@ -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/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/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/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/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/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/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
447
internal/app/service.go
Normal file
447
internal/app/service.go
Normal 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:]
|
||||
}
|
||||
120
internal/app/service_test.go
Normal file
120
internal/app/service_test.go
Normal 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
102
internal/app/types.go
Normal 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"`
|
||||
}
|
||||
@ -7,13 +7,26 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
func newAliasCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
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())
|
||||
return c
|
||||
@ -25,6 +38,18 @@ func newAliasAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
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 {
|
||||
if name == "" {
|
||||
return fmt.Errorf("--name is required")
|
||||
@ -53,7 +78,7 @@ func newAliasAddCmd() *cobra.Command {
|
||||
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().BoolVar(&disabled, "disabled", false, "create in disabled state")
|
||||
return cmd
|
||||
@ -63,6 +88,16 @@ func newAliasListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
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 {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -107,19 +142,46 @@ func newAliasBindCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "bind",
|
||||
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 {
|
||||
if alias == "" || provider == "" || model == "" {
|
||||
return fmt.Errorf("--alias, --provider and --model are required")
|
||||
if alias == "" || model == "" {
|
||||
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()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if err := validateProviderModelKnown(provider, p.Models, p.ModelsSource, model); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.FindAlias(alias) == nil {
|
||||
// auto-create enabled alias for ergonomics
|
||||
cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true})
|
||||
}
|
||||
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(&provider, "provider", "", "upstream provider id (required)")
|
||||
cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)")
|
||||
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (fallback; prefer --model provider/model)")
|
||||
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")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAliasUnbindCmd() *cobra.Command {
|
||||
var alias, provider, model string
|
||||
cmd := &cobra.Command{
|
||||
Use: "unbind",
|
||||
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 {
|
||||
if alias == "" || provider == "" || model == "" {
|
||||
return fmt.Errorf("--alias, --provider and --model are required")
|
||||
if alias == "" || model == "" {
|
||||
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()
|
||||
if err != nil {
|
||||
@ -163,8 +248,8 @@ func newAliasUnbindCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)")
|
||||
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)")
|
||||
cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)")
|
||||
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (fallback; prefer --model provider/model)")
|
||||
cmd.Flags().StringVar(&model, "model", "", "upstream target model, or provider/model when --provider is omitted (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -173,6 +258,16 @@ func newAliasRemoveCmd() *cobra.Command {
|
||||
Use: "remove <alias>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
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 {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,57 +4,51 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func newDoctorCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
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 {
|
||||
cfg, err := loadCfg()
|
||||
report, err := appService().RunDoctor(cmd.Context())
|
||||
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
|
||||
}
|
||||
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 := 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
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", report.ConfigPath)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
|
||||
marker := "(will be created)"
|
||||
if existed {
|
||||
if report.OpenCodeTargetFound {
|
||||
marker = "(exists)"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " provider.olpx preview: valid=%v\n", ok)
|
||||
|
||||
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))
|
||||
}
|
||||
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 nil
|
||||
},
|
||||
}
|
||||
|
||||
@ -5,13 +5,24 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
func newOpencodeCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "opencode",
|
||||
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())
|
||||
return c
|
||||
@ -24,60 +35,50 @@ func newOpencodeSyncCmd() *cobra.Command {
|
||||
var dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Update provider.olpx in the global OpenCode config to match current aliases",
|
||||
Long: `olpx opencode sync writes provider.olpx into the target OpenCode config.
|
||||
Short: "Update provider.ocswitch in the global OpenCode config to match current aliases",
|
||||
Long: `ocswitch opencode sync writes provider.ocswitch into the target OpenCode config.
|
||||
|
||||
By default it targets the global user config (~/.config/opencode), picking the
|
||||
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
|
||||
"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 {
|
||||
cfg, err := loadCfg()
|
||||
result, err := appService().ApplyOpenCodeSync(cmd.Context(), app.SyncInput{
|
||||
Target: target,
|
||||
SetModel: setModel,
|
||||
SetSmallModel: setSmallModel,
|
||||
DryRun: dryRun,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if errs := cfg.Validate(); len(errs) > 0 {
|
||||
for _, e := range errs {
|
||||
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)
|
||||
if !result.Changed {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", result.TargetPath)
|
||||
return nil
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", path)
|
||||
if result.DryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", result.TargetPath)
|
||||
return nil
|
||||
}
|
||||
if err := opencode.Save(path, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.olpx into %s (%d alias(es))\n", path, len(aliasNames))
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", result.TargetPath, len(result.AliasNames))
|
||||
if setModel != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
|
||||
}
|
||||
|
||||
33
internal/cli/opencode_validate.go
Normal file
33
internal/cli/opencode_validate.go
Normal 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, ", "))
|
||||
}
|
||||
@ -2,19 +2,32 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func newProviderCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "provider",
|
||||
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(
|
||||
newProviderAddCmd(),
|
||||
@ -30,10 +43,38 @@ func newProviderCmd() *cobra.Command {
|
||||
func newProviderAddCmd() *cobra.Command {
|
||||
var id, name, baseURL, apiKey string
|
||||
var headers []string
|
||||
var clearHeaders bool
|
||||
var disabled bool
|
||||
var skipModels bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
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 {
|
||||
if id == "" || baseURL == "" {
|
||||
return fmt.Errorf("--id and --base-url are required")
|
||||
@ -45,35 +86,76 @@ func newProviderAddCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
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 {
|
||||
k, v, ok := strings.Cut(h, "=")
|
||||
if !ok {
|
||||
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{
|
||||
ID: id,
|
||||
Name: name,
|
||||
BaseURL: baseURL,
|
||||
BaseURL: config.NormalizeProviderBaseURL(baseURL),
|
||||
APIKey: apiKey,
|
||||
Headers: hdrs,
|
||||
Headers: normalizeProviderHeaders(hdrs),
|
||||
Disabled: disabled,
|
||||
}
|
||||
connectionChanged := false
|
||||
if existing := cfg.FindProvider(id); existing != nil {
|
||||
if p.Name == "" {
|
||||
p.Name = existing.Name
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
if !apiKeyChanged {
|
||||
p.APIKey = existing.APIKey
|
||||
}
|
||||
if len(headers) == 0 && len(existing.Headers) > 0 {
|
||||
if !headersChanged && !clearHeadersRequested && len(existing.Headers) > 0 {
|
||||
p.Headers = cloneHeaders(existing.Headers)
|
||||
}
|
||||
if !disabled {
|
||||
if !disabledChanged {
|
||||
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)
|
||||
if err := cfg.Save(); err != nil {
|
||||
@ -84,6 +166,9 @@ func newProviderAddCmd() *cobra.Command {
|
||||
state = "disabled"
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
@ -92,10 +177,14 @@ func newProviderAddCmd() *cobra.Command {
|
||||
cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)")
|
||||
cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key")
|
||||
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(&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
|
||||
}
|
||||
|
||||
func cloneHeaders(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
@ -111,24 +200,31 @@ func newProviderListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
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 {
|
||||
cfg, err := loadCfg()
|
||||
providers, err := appService().ListProviders(cmd.Context())
|
||||
if err != nil {
|
||||
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 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
|
||||
return nil
|
||||
}
|
||||
for _, p := range providers {
|
||||
key := "(none)"
|
||||
if p.APIKey != "" {
|
||||
key = maskKey(p.APIKey)
|
||||
if p.APIKeySet {
|
||||
key = p.APIKeyMasked
|
||||
}
|
||||
state := "enabled"
|
||||
if !p.IsEnabled() {
|
||||
if p.Disabled {
|
||||
state = "disabled"
|
||||
}
|
||||
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>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
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 {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -181,6 +287,16 @@ func newProviderRemoveCmd() *cobra.Command {
|
||||
Use: "remove <id>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
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 {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -204,13 +320,35 @@ func newProviderImportCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import-opencode",
|
||||
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 {
|
||||
fromChanged := cmd.Flags().Changed("from")
|
||||
if srcPath == "" {
|
||||
p, existed := opencode.ResolveGlobalConfigPath()
|
||||
if !existed {
|
||||
return fmt.Errorf("no OpenCode config found at %s; use --from to specify", 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)
|
||||
if err != nil {
|
||||
@ -233,15 +371,23 @@ func newProviderImportCmd() *cobra.Command {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "skip %q (already exists, use --overwrite)\n", ip.ID)
|
||||
continue
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{
|
||||
ID: ip.ID,
|
||||
Name: ip.Name,
|
||||
BaseURL: ip.BaseURL,
|
||||
APIKey: ip.APIKey,
|
||||
Disabled: false,
|
||||
baseURL := config.NormalizeProviderBaseURL(ip.BaseURL)
|
||||
if err := config.ValidateProviderBaseURL(baseURL); err != nil {
|
||||
skipped++
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "skip %q (invalid baseURL %q: %v)\n", ip.ID, ip.BaseURL, err)
|
||||
continue
|
||||
}
|
||||
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++
|
||||
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 err := cfg.Save(); err != nil {
|
||||
@ -257,6 +403,79 @@ func newProviderImportCmd() *cobra.Command {
|
||||
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.
|
||||
func maskKey(k string) string {
|
||||
if len(k) <= 8 {
|
||||
|
||||
49
internal/cli/provider_model_ref.go
Normal file
49
internal/cli/provider_model_ref.go
Normal 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, ", "))
|
||||
}
|
||||
32
internal/cli/provider_model_ref_test.go
Normal file
32
internal/cli/provider_model_ref_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// Package cli wires the olpx cobra command tree.
|
||||
// Package cli wires the ocswitch cobra command tree.
|
||||
package cli
|
||||
|
||||
import (
|
||||
@ -7,27 +7,55 @@ import (
|
||||
|
||||
"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.
|
||||
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) {
|
||||
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 {
|
||||
root := &cobra.Command{
|
||||
Use: "olpx",
|
||||
Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode",
|
||||
Use: config.AppName,
|
||||
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,
|
||||
SilenceErrors: false,
|
||||
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(newDoctorCmd())
|
||||
|
||||
@ -1,33 +1,40 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/proxy"
|
||||
)
|
||||
|
||||
func newServeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
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 {
|
||||
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)
|
||||
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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
// Package config manages the local olpx JSON config file.
|
||||
// Package config manages the local ocswitch JSON config file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"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.
|
||||
@ -29,12 +39,14 @@ type Alias struct {
|
||||
|
||||
// Provider is one upstream OpenAI-compatible endpoint.
|
||||
type Provider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
ModelsSource string `json:"models_source,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// Server holds proxy listen settings.
|
||||
@ -44,9 +56,17 @@ type Server struct {
|
||||
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 {
|
||||
Server Server `json:"server"`
|
||||
Desktop Desktop `json:"desktop,omitempty"`
|
||||
Providers []Provider `json:"providers"`
|
||||
Aliases []Alias `json:"aliases"`
|
||||
|
||||
@ -59,10 +79,16 @@ func (p Provider) IsEnabled() bool {
|
||||
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
|
||||
// point at an OpenAI-compatible /v1 root.
|
||||
func ValidateProviderBaseURL(baseURL string) error {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
trimmed := NormalizeProviderBaseURL(baseURL)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("missing base_url")
|
||||
}
|
||||
@ -78,26 +104,27 @@ func Default() *Config {
|
||||
Server: Server{
|
||||
Host: "127.0.0.1",
|
||||
Port: 9982,
|
||||
APIKey: "olpx-local",
|
||||
APIKey: DefaultLocalAPIKey,
|
||||
},
|
||||
Desktop: Desktop{},
|
||||
Providers: []Provider{},
|
||||
Aliases: []Alias{},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPath returns ~/.config/olpx/config.json (XDG aware).
|
||||
// DefaultPath returns ~/.config/ocswitch/config.json (XDG aware).
|
||||
func DefaultPath() string {
|
||||
if p := os.Getenv("OLPX_CONFIG"); p != "" {
|
||||
if p := os.Getenv(ConfigEnvVar); p != "" {
|
||||
return p
|
||||
}
|
||||
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()
|
||||
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.
|
||||
@ -127,52 +154,60 @@ func Load(path string) (*Config, error) {
|
||||
c.Server.Port = 9982
|
||||
}
|
||||
if c.Server.APIKey == "" {
|
||||
c.Server.APIKey = "olpx-local"
|
||||
c.Server.APIKey = DefaultLocalAPIKey
|
||||
}
|
||||
c.path = path
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) Save() error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.path == "" {
|
||||
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)
|
||||
}
|
||||
// sort for stability
|
||||
providers := append([]Provider(nil), c.Providers...)
|
||||
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
||||
aliases := append([]Alias(nil), c.Aliases...)
|
||||
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
||||
snap := struct {
|
||||
Server Server `json:"server"`
|
||||
Providers []Provider `json:"providers"`
|
||||
Aliases []Alias `json:"aliases"`
|
||||
}{c.Server, providers, aliases}
|
||||
data, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmp := c.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write tmp: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, c.path)
|
||||
return fileutil.WithLockedFile(path, func() error {
|
||||
providers := cloneProviders(c.Providers)
|
||||
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
||||
aliases := cloneAliases(c.Aliases)
|
||||
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
||||
snap := struct {
|
||||
Server Server `json:"server"`
|
||||
Desktop Desktop `json:"desktop,omitempty"`
|
||||
Providers []Provider `json:"providers"`
|
||||
Aliases []Alias `json:"aliases"`
|
||||
}{c.Server, c.Desktop, providers, aliases}
|
||||
data, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return fileutil.AtomicWriteFile(path, data, 0o600)
|
||||
})
|
||||
}
|
||||
|
||||
// FindProvider returns the provider with matching id or nil.
|
||||
func (c *Config) FindProvider(id string) *Provider {
|
||||
c.mu.RLock()
|
||||
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 {
|
||||
@ -188,13 +223,14 @@ func (c *Config) findProviderLocked(id string) *Provider {
|
||||
func (c *Config) UpsertProvider(p Provider) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cloned := cloneProvider(p)
|
||||
for i := range c.Providers {
|
||||
if c.Providers[i].ID == p.ID {
|
||||
c.Providers[i] = p
|
||||
c.Providers[i] = cloned
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Providers = append(c.Providers, p)
|
||||
c.Providers = append(c.Providers, cloned)
|
||||
}
|
||||
|
||||
// RemoveProvider deletes a provider and returns true if removed.
|
||||
@ -216,7 +252,8 @@ func (c *Config) FindAlias(name string) *Alias {
|
||||
defer c.mu.RUnlock()
|
||||
for i := range c.Aliases {
|
||||
if c.Aliases[i].Alias == name {
|
||||
return &c.Aliases[i]
|
||||
clone := cloneAlias(c.Aliases[i])
|
||||
return &clone
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@ -226,13 +263,14 @@ func (c *Config) FindAlias(name string) *Alias {
|
||||
func (c *Config) UpsertAlias(a Alias) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cloned := cloneAlias(a)
|
||||
for i := range c.Aliases {
|
||||
if c.Aliases[i].Alias == a.Alias {
|
||||
c.Aliases[i] = a
|
||||
c.Aliases[i] = cloned
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Aliases = append(c.Aliases, a)
|
||||
c.Aliases = append(c.Aliases, cloned)
|
||||
}
|
||||
|
||||
// RemoveAlias deletes an alias.
|
||||
@ -356,6 +394,26 @@ func (c *Config) Validate() []error {
|
||||
if err := ValidateProviderBaseURL(p.BaseURL); err != nil {
|
||||
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{}
|
||||
for _, a := range c.Aliases {
|
||||
@ -385,5 +443,66 @@ func (c *Config) Validate() []error {
|
||||
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
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}},
|
||||
Aliases: []Alias{{
|
||||
Alias: "gpt-5.4",
|
||||
@ -110,3 +141,134 @@ func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
28
internal/config/provider_models.go
Normal file
28
internal/config/provider_models.go
Normal 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)
|
||||
}
|
||||
16
internal/config/provider_models_test.go
Normal file
16
internal/config/provider_models_test.go
Normal 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
167
internal/desktop/app.go
Normal 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
|
||||
}
|
||||
98
internal/desktop/autostart.go
Normal file
98
internal/desktop/autostart.go
Normal 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) + `"`
|
||||
}
|
||||
59
internal/desktop/autostart_test.go
Normal file
59
internal/desktop/autostart_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
114
internal/desktop/bindings.go
Normal file
114
internal/desktop/bindings.go
Normal 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
323
internal/desktop/http.go
Normal 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}}
|
||||
}
|
||||
}
|
||||
104
internal/desktop/http_test.go
Normal file
104
internal/desktop/http_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
25
internal/desktop/notify.go
Normal file
25
internal/desktop/notify.go
Normal 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
|
||||
}
|
||||
10
internal/desktop/runtime_fallback.go
Normal file
10
internal/desktop/runtime_fallback.go
Normal file
@ -0,0 +1,10 @@
|
||||
//go:build !desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import "context"
|
||||
|
||||
func hideWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
14
internal/desktop/runtime_wails.go
Normal file
14
internal/desktop/runtime_wails.go
Normal 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
41
internal/desktop/tray.go
Normal 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
58
internal/desktop/wails.go
Normal 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")
|
||||
}
|
||||
96
internal/fileutil/fileutil.go
Normal file
96
internal/fileutil/fileutil.go
Normal 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
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// 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.
|
||||
package opencode
|
||||
|
||||
@ -13,9 +13,15 @@ import (
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/fileutil"
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderKey = "ocswitch"
|
||||
ProviderName = "OpenCode Provider Switch CLI"
|
||||
)
|
||||
|
||||
// ConfigFileCandidates is the precedence order inside the global config dir.
|
||||
var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"}
|
||||
|
||||
@ -72,25 +78,25 @@ func Load(path string) (Raw, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save writes provider.olpx back to path. Existing files are normalized to plain
|
||||
// JSON and only the provider.olpx subtree is patched so unrelated key order stays
|
||||
// Save writes provider.ocswitch back to path. Existing files are normalized to plain
|
||||
// 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
|
||||
// atomic.
|
||||
func Save(path string, raw Raw) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
data, err := renderSaveData(path, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write tmp: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
return fileutil.WithLockedFile(path, func() error {
|
||||
data, err := renderSaveData(path, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.AtomicWriteFile(path, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func renderSaveData(path string, raw Raw) ([]byte, error) {
|
||||
original, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@ -102,7 +108,7 @@ func renderSaveData(path string, raw Raw) ([]byte, error) {
|
||||
if len(bytes.TrimSpace(original)) == 0 {
|
||||
return marshalRaw(raw)
|
||||
}
|
||||
patched, err := patchProviderOLPXDocument(original, raw)
|
||||
patched, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch %s: %w", path, err)
|
||||
}
|
||||
@ -122,8 +128,8 @@ func marshalRaw(raw Raw) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
olpxRaw, ok := olpxProviderValue(raw)
|
||||
func patchProviderDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
providerValue, ok := syncedProviderValue(raw)
|
||||
if !ok {
|
||||
return marshalRaw(raw)
|
||||
}
|
||||
@ -144,7 +150,7 @@ func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
}
|
||||
provider := root.findMember("provider")
|
||||
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] != '{' {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
olpx := providerObj.findMember("olpx")
|
||||
if olpx == nil {
|
||||
return insertObjectMember(normalized, providerObj, "olpx", olpxRaw)
|
||||
providerEntry := providerObj.findMember(ProviderKey)
|
||||
if providerEntry == nil {
|
||||
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)
|
||||
if providerRaw == nil {
|
||||
return nil, false
|
||||
}
|
||||
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
|
||||
if olpxRaw == nil {
|
||||
providerEntry, _ := providerRaw[ProviderKey].(map[string]any)
|
||||
if providerEntry == nil {
|
||||
return nil, false
|
||||
}
|
||||
return olpxRaw, true
|
||||
return providerEntry, true
|
||||
}
|
||||
|
||||
type objectSpan struct {
|
||||
@ -410,13 +416,13 @@ func lineIndent(data []byte, pos int) string {
|
||||
return string(data[lineStart:lineEnd])
|
||||
}
|
||||
|
||||
// EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given
|
||||
// local base URL, local api key and alias set. Existing keys on provider.olpx
|
||||
// EnsureOcswitchProvider updates (or creates) the provider.ocswitch entry with the given
|
||||
// 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,
|
||||
// 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
|
||||
// actually change.
|
||||
func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
|
||||
func EnsureOcswitchProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
|
||||
changed := false
|
||||
if _, ok := raw["$schema"]; !ok {
|
||||
raw["$schema"] = "https://opencode.ai/config.json"
|
||||
@ -428,22 +434,22 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
|
||||
raw["provider"] = provRaw
|
||||
changed = true
|
||||
}
|
||||
olpxRaw, _ := provRaw["olpx"].(map[string]any)
|
||||
if olpxRaw == nil {
|
||||
olpxRaw = map[string]any{}
|
||||
provRaw["olpx"] = olpxRaw
|
||||
providerEntry, _ := provRaw[ProviderKey].(map[string]any)
|
||||
if providerEntry == nil {
|
||||
providerEntry = map[string]any{}
|
||||
provRaw[ProviderKey] = providerEntry
|
||||
changed = true
|
||||
}
|
||||
if setIfDiff(olpxRaw, "npm", "@ai-sdk/openai") {
|
||||
if setIfDiff(providerEntry, "npm", "@ai-sdk/openai") {
|
||||
changed = true
|
||||
}
|
||||
if setIfDiff(olpxRaw, "name", "OpenCode LocalProxy CLI") {
|
||||
if setIfDiff(providerEntry, "name", ProviderName) {
|
||||
changed = true
|
||||
}
|
||||
opts, _ := olpxRaw["options"].(map[string]any)
|
||||
opts, _ := providerEntry["options"].(map[string]any)
|
||||
if opts == nil {
|
||||
opts = map[string]any{}
|
||||
olpxRaw["options"] = opts
|
||||
providerEntry["options"] = opts
|
||||
changed = true
|
||||
}
|
||||
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
|
||||
// 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{}
|
||||
aliasSet := map[string]bool{}
|
||||
for _, a := range aliases {
|
||||
@ -476,43 +482,43 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
|
||||
}
|
||||
}
|
||||
if !mapsEqualShallow(existingModels, newModels) {
|
||||
olpxRaw["models"] = newModels
|
||||
providerEntry["models"] = newModels
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// ValidateOLPXProvider checks that provider.olpx matches the MVP sync contract.
|
||||
func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
|
||||
// ValidateOcswitchProvider checks that provider.ocswitch matches the MVP sync contract.
|
||||
func ValidateOcswitchProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
|
||||
provRaw, _ := raw["provider"].(map[string]any)
|
||||
if provRaw == nil {
|
||||
return fmt.Errorf("missing provider object")
|
||||
}
|
||||
olpxRaw, _ := provRaw["olpx"].(map[string]any)
|
||||
if olpxRaw == nil {
|
||||
return fmt.Errorf("missing provider.olpx")
|
||||
providerEntry, _ := provRaw[ProviderKey].(map[string]any)
|
||||
if providerEntry == nil {
|
||||
return fmt.Errorf("missing provider.%s", ProviderKey)
|
||||
}
|
||||
if npm, _ := olpxRaw["npm"].(string); npm != "@ai-sdk/openai" {
|
||||
return fmt.Errorf("provider.olpx.npm must be @ai-sdk/openai")
|
||||
if npm, _ := providerEntry["npm"].(string); npm != "@ai-sdk/openai" {
|
||||
return fmt.Errorf("provider.%s.npm must be @ai-sdk/openai", ProviderKey)
|
||||
}
|
||||
if name, _ := olpxRaw["name"].(string); name != "OpenCode LocalProxy CLI" {
|
||||
return fmt.Errorf("provider.olpx.name must be OpenCode LocalProxy CLI")
|
||||
if name, _ := providerEntry["name"].(string); name != ProviderName {
|
||||
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 {
|
||||
return fmt.Errorf("provider.olpx.options missing")
|
||||
return fmt.Errorf("provider.%s.options missing", ProviderKey)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("provider.olpx.models missing")
|
||||
return fmt.Errorf("provider.%s.models missing", ProviderKey)
|
||||
}
|
||||
expected := append([]string(nil), aliases...)
|
||||
sort.Strings(expected)
|
||||
@ -520,17 +526,17 @@ func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) err
|
||||
for alias, v := range models {
|
||||
modelCfg, _ := v.(map[string]any)
|
||||
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)
|
||||
}
|
||||
sort.Strings(actual)
|
||||
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 {
|
||||
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
|
||||
@ -556,13 +562,13 @@ type ImportableProvider struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ImportCustomProviders(raw Raw) []ImportableProvider {
|
||||
out := []ImportableProvider{}
|
||||
provRaw, _ := raw["provider"].(map[string]any)
|
||||
for id, v := range provRaw {
|
||||
if id == "olpx" {
|
||||
if id == ProviderKey {
|
||||
continue
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
@ -594,6 +600,7 @@ func ImportCustomProviders(raw Raw) []ImportableProvider {
|
||||
for k := range models {
|
||||
ip.Models = append(ip.Models, k)
|
||||
}
|
||||
sort.Strings(ip.Models)
|
||||
}
|
||||
out = append(out, ip)
|
||||
}
|
||||
|
||||
@ -64,35 +64,35 @@ func TestResolveGlobalConfigPathPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOLPXProvider(t *testing.T) {
|
||||
func TestValidateOcswitchProvider(t *testing.T) {
|
||||
raw := Raw{}
|
||||
aliases := []string{"gpt-5.4", "gpt-5.4-mini"}
|
||||
baseURL := "http://127.0.0.1:9982/v1"
|
||||
apiKey := "olpx-local"
|
||||
EnsureOLPXProvider(raw, baseURL, apiKey, aliases)
|
||||
apiKey := "ocswitch-local"
|
||||
EnsureOcswitchProvider(raw, baseURL, apiKey, aliases)
|
||||
|
||||
providerRaw, _ := raw["provider"].(map[string]any)
|
||||
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
|
||||
opts, _ := olpxRaw["options"].(map[string]any)
|
||||
providerEntry, _ := providerRaw[ProviderKey].(map[string]any)
|
||||
opts, _ := providerEntry["options"].(map[string]any)
|
||||
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 {
|
||||
t.Fatalf("ValidateOLPXProvider() unexpected error: %v", err)
|
||||
if err := ValidateOcswitchProvider(raw, baseURL, apiKey, aliases); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) {
|
||||
func TestEnsureOcswitchProviderPreservesExistingModelMetadata(t *testing.T) {
|
||||
raw := Raw{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"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 {
|
||||
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)
|
||||
olpxRaw := providerRaw["olpx"].(map[string]any)
|
||||
models := olpxRaw["models"].(map[string]any)
|
||||
providerEntry := providerRaw[ProviderKey].(map[string]any)
|
||||
models := providerEntry["models"].(map[string]any)
|
||||
model := models["gpt-5.4"].(map[string]any)
|
||||
if got := model["name"]; got != "custom-display-name" {
|
||||
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{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{
|
||||
@ -169,25 +169,25 @@ func TestEnsureOLPXProviderDoesNotPanicOnComparableMetadata(t *testing.T) {
|
||||
|
||||
defer func() {
|
||||
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 {
|
||||
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{
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"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 {
|
||||
t.Fatalf("ValidateOLPXProvider() unexpected error for custom metadata: %v", err)
|
||||
if err := ValidateOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider() unexpected error for custom metadata: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataReplacesExistingProviderOLPXOnly(t *testing.T) {
|
||||
func TestPatchProviderDocumentReplacesExistingProviderOnly(t *testing.T) {
|
||||
raw := Raw{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "olpx/gpt-5.4",
|
||||
"model": "ocswitch/gpt-5.4",
|
||||
"provider": map[string]any{
|
||||
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
|
||||
},
|
||||
"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 {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
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"`) {
|
||||
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
|
||||
if err := json.Unmarshal(got, &saved); err != nil {
|
||||
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 {
|
||||
t.Fatalf("ValidateOLPXProvider(saved) error: %v", err)
|
||||
if err := ValidateOcswitchProvider(saved, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider(saved) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(t *testing.T) {
|
||||
func TestPatchProviderDocumentInsertsOcswitchWithoutReorderingProviderKeys(t *testing.T) {
|
||||
raw := Raw{
|
||||
"provider": map[string]any{
|
||||
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"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"},
|
||||
},
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
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{
|
||||
"model": "olpx/gpt-5.4",
|
||||
"model": "ocswitch/gpt-5.4",
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"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 {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`})
|
||||
}
|
||||
|
||||
func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
|
||||
func TestPatchProviderDocumentAcceptsJSONCAndProducesValidJSON(t *testing.T) {
|
||||
raw := Raw{
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"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")
|
||||
|
||||
got, err := patchProviderOLPXDocument(original, raw)
|
||||
got, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
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{
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
"openai-empty": map[string]any{
|
||||
"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{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) {
|
||||
func TestPatchProviderDocumentRejectsNonObjectProvider(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) {
|
||||
func TestPatchProviderDocumentRejectsNonObjectTopLevel(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
if _, err := patchProviderOLPXDocument([]byte("{} trailing"), raw); err == nil {
|
||||
if _, err := patchProviderDocument([]byte(`{} trailing`), raw); err == nil {
|
||||
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")
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
@ -388,14 +437,14 @@ func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("ValidateOLPXProvider(loaded) error: %v", err)
|
||||
if err := ValidateOcswitchProvider(loaded, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider(loaded) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("write seed config: %v", err)
|
||||
}
|
||||
@ -404,9 +453,9 @@ func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
@ -417,8 +466,8 @@ func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
|
||||
t.Fatalf("Load(saved) error: %v", err)
|
||||
}
|
||||
providerRaw := loaded["provider"].(map[string]any)
|
||||
olpxRaw := providerRaw["olpx"].(map[string]any)
|
||||
models := olpxRaw["models"].(map[string]any)
|
||||
providerEntry := providerRaw[ProviderKey].(map[string]any)
|
||||
models := providerEntry["models"].(map[string]any)
|
||||
model := models["gpt-5.4"].(map[string]any)
|
||||
if got := model["name"]; got != "custom-display-name" {
|
||||
t.Fatalf("saved model name = %#v, want custom-display-name preserved", got)
|
||||
|
||||
65
internal/opencode/provider_models.go
Normal file
65
internal/opencode/provider_models.go
Normal 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
|
||||
}
|
||||
99
internal/opencode/provider_models_test.go
Normal file
99
internal/opencode/provider_models_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
import (
|
||||
@ -17,22 +14,30 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
var firstByteTimeout = 15 * time.Second
|
||||
var requestReadTimeout = 30 * time.Second
|
||||
var streamIdleTimeout = 60 * time.Second
|
||||
|
||||
type openAIErrorEnvelope struct {
|
||||
Error openAIError `json:"error"`
|
||||
}
|
||||
|
||||
type upstreamFailure struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
type openAIError struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Server is the local olpx HTTP proxy.
|
||||
// Server is the local ocswitch HTTP proxy.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
@ -52,17 +57,16 @@ func New(cfg *config.Config) *Server {
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: firstByteTimeout,
|
||||
// no response buffering so streams flow through immediately
|
||||
DisableCompression: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
DisableCompression: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
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,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: requestReadTimeout,
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
s.logger.Printf("listening on http://%s", addr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
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{
|
||||
"id": aliasName,
|
||||
"object": "model",
|
||||
"owned_by": "olpx",
|
||||
"owned_by": config.AppName,
|
||||
})
|
||||
}
|
||||
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))
|
||||
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
|
||||
}
|
||||
var payload map[string]any
|
||||
@ -182,6 +187,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
failoverCount := 0
|
||||
var lastRetryable *upstreamFailure
|
||||
for attempt, t := range targets {
|
||||
p := s.cfg.FindProvider(t.Provider)
|
||||
if p == nil || !p.IsEnabled() {
|
||||
@ -190,7 +196,6 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
// rewrite payload.model for this upstream
|
||||
cloned := cloneMap(payload)
|
||||
cloned["model"] = t.Model
|
||||
newBody, err := json.Marshal(cloned)
|
||||
@ -200,24 +205,34 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
if failure != nil {
|
||||
lastRetryable = failure
|
||||
}
|
||||
s.logger.Printf("req=%d alias=%s attempt=%d retryable: %v", reqID, aliasName, attempt+1, upstreamErr)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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.
|
||||
// retryable=true means failure happened before any bytes flushed downstream.
|
||||
func (s *Server) tryOnce(
|
||||
@ -230,11 +245,14 @@ func (s *Server) tryOnce(
|
||||
aliasName string,
|
||||
attempt 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"
|
||||
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
|
||||
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)
|
||||
upReq.Header.Set("Content-Type", "application/json")
|
||||
@ -250,46 +268,41 @@ func (s *Server) tryOnce(
|
||||
startedAt := time.Now()
|
||||
resp, err := s.client.Do(upReq)
|
||||
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()
|
||||
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
|
||||
// drain up to small cap for logging context
|
||||
peek, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(peek), 200))
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
|
||||
failure = captureRetryableFailure(resp)
|
||||
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure
|
||||
}
|
||||
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.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
|
||||
copyResponseHeaders(w.Header(), resp.Header)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
return true, false, fmt.Errorf("upstream %d", resp.StatusCode)
|
||||
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
|
||||
}
|
||||
|
||||
remaining := firstByteTimeout - time.Since(startedAt)
|
||||
if remaining <= 0 {
|
||||
_ = resp.Body.Close()
|
||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout)
|
||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||
}
|
||||
firstChunk, firstErr := readFirstChunk(resp.Body, remaining)
|
||||
if firstErr != nil {
|
||||
if errors.Is(firstErr, errFirstByteTimeout) {
|
||||
_ = resp.Body.Close()
|
||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout)
|
||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||
}
|
||||
if errors.Is(firstErr, io.EOF) {
|
||||
if len(firstChunk) == 0 {
|
||||
firstChunk = nil
|
||||
}
|
||||
} 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.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
|
||||
copyResponseHeaders(w.Header(), resp.Header)
|
||||
@ -297,7 +310,7 @@ func (s *Server) tryOnce(
|
||||
flusher, _ := w.(http.Flusher)
|
||||
if len(firstChunk) > 0 {
|
||||
if _, werr := w.Write(firstChunk); werr != nil {
|
||||
return true, false, werr
|
||||
return true, false, werr, nil
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@ -305,10 +318,10 @@ func (s *Server) tryOnce(
|
||||
}
|
||||
buf := make([]byte, 16<<10)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
n, rerr := readChunkWithTimeout(resp.Body, buf, streamIdleTimeout)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return true, false, werr
|
||||
return true, false, werr, nil
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@ -316,15 +329,15 @@ func (s *Server) tryOnce(
|
||||
}
|
||||
if rerr != nil {
|
||||
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
|
||||
return true, false, rerr, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errFirstByteTimeout = errors.New("first byte timeout")
|
||||
var errStreamIdleTimeout = errors.New("stream idle timeout")
|
||||
|
||||
func readFirstChunk(r io.Reader, timeout time.Duration) ([]byte, error) {
|
||||
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) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "application/json")
|
||||
@ -370,8 +421,20 @@ func errorTypeForStatus(status int) string {
|
||||
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 {
|
||||
const prefix = "olpx/"
|
||||
prefix := config.AppName + "/"
|
||||
if strings.HasPrefix(model, prefix) {
|
||||
trimmed := strings.TrimPrefix(model, prefix)
|
||||
if trimmed != "" {
|
||||
@ -381,17 +444,16 @@ func normalizeAliasName(model string) string {
|
||||
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) {
|
||||
h := w.Header()
|
||||
h.Set("X-OLPX-Alias", alias)
|
||||
h.Set("X-OLPX-Provider", provider)
|
||||
h.Set("X-OLPX-Remote-Model", remoteModel)
|
||||
h.Set("X-OLPX-Attempt", fmt.Sprintf("%d", attempt))
|
||||
h.Set("X-OLPX-Failover-Count", fmt.Sprintf("%d", failoverCount))
|
||||
h.Set("X-OCSWITCH-Alias", alias)
|
||||
h.Set("X-OCSWITCH-Provider", provider)
|
||||
h.Set("X-OCSWITCH-Remote-Model", remoteModel)
|
||||
h.Set("X-OCSWITCH-Attempt", fmt.Sprintf("%d", attempt))
|
||||
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{
|
||||
"Connection": true,
|
||||
"Proxy-Connection": true,
|
||||
@ -404,16 +466,15 @@ var hopByHopHeaders = map[string]bool{
|
||||
"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) {
|
||||
connectionHeaders := connectionDeclaredHeaders(src)
|
||||
for k, vs := range src {
|
||||
ck := http.CanonicalHeaderKey(k)
|
||||
if hopByHopHeaders[ck] {
|
||||
if hopByHopHeaders[ck] || connectionHeaders[ck] {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
for k, vs := range src {
|
||||
ck := http.CanonicalHeaderKey(k)
|
||||
@ -430,7 +503,6 @@ func copyResponseHeaders(dst, src http.Header) {
|
||||
continue
|
||||
}
|
||||
if ck == "Content-Length" {
|
||||
// We may transform nothing, but streaming responses often omit this.
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
@ -448,10 +519,9 @@ func cloneMap(m map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// truncate returns s truncated to at most n bytes (best-effort).
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
@ -11,17 +11,17 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -64,7 +64,7 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
defer second.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"},
|
||||
{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.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"ocswitch/gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
@ -96,14 +96,14 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
if secondSeenModel != "up-2" {
|
||||
t.Fatalf("second upstream model = %q, want up-2", secondSeenModel)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" {
|
||||
t.Fatalf("X-OLPX-Attempt = %q, want 2", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "2" {
|
||||
t.Fatalf("X-OCSWITCH-Attempt = %q, want 2", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" {
|
||||
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "1" {
|
||||
t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 1", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,7 +124,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||
defer second.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: first.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.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -149,14 +149,119 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||
if calledSecond {
|
||||
t.Fatal("second upstream should not be called for 400 response")
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" {
|
||||
t.Fatalf("X-OLPX-Provider = %q, want p1", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p1" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p1", got)
|
||||
}
|
||||
if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@ -181,7 +286,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
|
||||
defer enabled.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true},
|
||||
{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.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
@ -210,14 +315,14 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
|
||||
if seenModel != "up-2" {
|
||||
t.Fatalf("enabled upstream model = %q, want up-2", seenModel)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Attempt"); got != "1" {
|
||||
t.Fatalf("X-OLPX-Attempt = %q, want 1", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "1" {
|
||||
t.Fatalf("X-OCSWITCH-Attempt = %q, want 1", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "0" {
|
||||
t.Fatalf("X-OLPX-Failover-Count = %q, want 0", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "0" {
|
||||
t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 0", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
||||
}
|
||||
if body := rr.Body.String(); body != "data: ok\n\n" {
|
||||
t.Fatalf("body = %q, want SSE payload", body)
|
||||
@ -228,7 +333,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: "https://p1.example.com/v1"},
|
||||
{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.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@ -309,8 +426,12 @@ func TestReadFirstChunk(t *testing.T) {
|
||||
}
|
||||
|
||||
type blockingReader 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) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
24
main_wails.go
Normal file
24
main_wails.go
Normal 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
12
wails.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user