refactor(cli): rename tool to ocswitch

Avoid collision with the existing Opswitch product while keeping the repository name as opencode-provider-switch.

This aligns the CLI, provider key, config paths, examples, and Trellis history under one public-facing name.
This commit is contained in:
apale7 2026-04-17 21:43:21 +08:00
parent 594ecadfdd
commit cf3dcec399
29 changed files with 664 additions and 652 deletions

View File

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

View File

@ -1,8 +1,8 @@
{ {
"id": "forwarding-log-viewer-gui", "id": "forwarding-log-viewer-gui",
"name": "forwarding-log-viewer-gui", "name": "forwarding-log-viewer-gui",
"title": "Design forwarding log viewer GUI for olpx", "title": "Design forwarding log viewer GUI for ocswitch",
"description": "Define a minimal local web UI and request logging design for olpx forwarding operations, including provider/model visibility, token usage, and failover display.", "description": "Define a minimal local web UI and request logging design for ocswitch forwarding operations, including provider/model visibility, token usage, and failover display.",
"status": "completed", "status": "completed",
"dev_type": "docs", "dev_type": "docs",
"scope": "observability", "scope": "observability",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,7 @@
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 5 | 2026-04-17 | Fix sync command panic | `887eb14` | `master` | | 5 | 2026-04-17 | Fix sync command panic | `887eb14` | `master` |
| 4 | 2026-04-17 | Review MVP completion status | - | `master` | | 4 | 2026-04-17 | Review MVP completion status | - | `master` |
| 3 | 2026-04-17 | Support disabling providers in olpx | HEAD | `master` | | 3 | 2026-04-17 | Support disabling providers in ocswitch | HEAD | `master` |
| 2 | 2026-04-17 | Build OPS MVP failover proxy | `2b04d91` | `master` | | 2 | 2026-04-17 | Build OPS MVP failover proxy | `2b04d91` | `master` |
| 1 | 2026-04-17 | Finalize OPS MVP review PRD | `eeacdc4`, `00747fa`, `ca0b3c4` | `master` | | 1 | 2026-04-17 | Finalize OPS MVP review PRD | `eeacdc4`, `00747fa`, `ca0b3c4` | `master` |
<!-- @@@/auto:session-history --> <!-- @@@/auto:session-history -->

View File

@ -91,10 +91,10 @@ Implemented the Go-based OPS MVP: local config, provider and alias CLI, OpenCode
- None - task complete - None - task complete
## Session 3: Support disabling providers in olpx ## Session 3: Support disabling providers in ocswitch
**Date**: 2026-04-17 **Date**: 2026-04-17
**Task**: Support disabling providers in olpx **Task**: Support disabling providers in ocswitch
**Branch**: `master` **Branch**: `master`
### Summary ### Summary
@ -139,7 +139,7 @@ Reviewed current implementation against the archived MVP PRD and classified impl
| Category | Result | | Category | Result |
|---|---| |---|---|
| Overall status | MVP core flow is effectively complete | | Overall status | MVP core flow is effectively complete |
| Implemented MVP | Local olpx config, provider/alias CLI, OpenCode sync, static doctor, `/v1/responses` proxy, streaming pass-through, pre-first-byte failover, debug headers | | Implemented MVP | Local ocswitch config, provider/alias CLI, OpenCode sync, static doctor, `/v1/responses` proxy, streaming pass-through, pre-first-byte failover, debug headers |
| Partial / missing MVP edges | `doctor` validates generated preview more than on-disk synced state; OpenCode provider import only reads `options.apiKey`, not broader header-style auth | | Partial / missing MVP edges | `doctor` validates generated preview more than on-disk synced state; OpenCode provider import only reads `options.apiKey`, not broader header-style auth |
| Beyond MVP | `provider enable/disable`, minimal `/v1/models`, broader alias normalization, careful OpenCode config patching | | Beyond MVP | `provider enable/disable`, minimal `/v1/models`, broader alias normalization, careful OpenCode config patching |
@ -191,7 +191,7 @@ Fixed a panic in opencode sync when preserved model metadata contains slices, an
### Main Changes ### Main Changes
- Root cause: `internal/opencode/opencode.go` compared `interface{}` values directly inside `mapsEqualShallow`, which panicked when preserved `provider.olpx.models.<alias>` metadata included slices. - Root cause: `internal/opencode/opencode.go` compared `interface{}` values directly inside `mapsEqualShallow`, which panicked when preserved `provider.ocswitch.models.<alias>` metadata included slices.
- Fix: replaced the unsafe hand-rolled comparison with `reflect.DeepEqual` so existing model metadata with nested maps/slices can be compared safely during `sync`. - Fix: replaced the unsafe hand-rolled comparison with `reflect.DeepEqual` so existing model metadata with nested maps/slices can be compared safely during `sync`.
- Added regression tests in `internal/opencode/opencode_test.go` and `internal/cli/cli_test.go` covering preserved slice metadata and a real `opencode sync --target` no-op path. - Added regression tests in `internal/opencode/opencode_test.go` and `internal/cli/cli_test.go` covering preserved slice metadata and a real `opencode sync --target` no-op path.
- Verification: ran `rtk go test ./internal/opencode`, `rtk go test ./internal/cli ./internal/opencode`, and `rtk go test ./...` successfully. - Verification: ran `rtk go test ./internal/opencode`, `rtk go test ./internal/cli ./internal/opencode`, and `rtk go test ./...` successfully.

174
README.md
View File

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

View File

@ -1,15 +1,15 @@
# opencode-provider-switch (`olpx`) # opencode-provider-switch (`ocswitch`)
A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one
stable model alias** routed to **multiple upstream providers** with stable model alias** routed to **multiple upstream providers** with
**deterministic failover**. **deterministic failover**.
- Expose one custom provider `olpx` to OpenCode. - Expose one custom provider `ocswitch` to OpenCode.
- Configure logical aliases (`olpx/gpt-5.4`, etc.). - Configure logical aliases (`ocswitch/gpt-5.4`, etc.).
- Each alias has an ordered list of upstream `provider/model` targets. - Each alias has an ordered list of upstream `provider/model` targets.
- Providers can be disabled without mutating alias target state. - Providers can be disabled without mutating alias target state.
- When the primary upstream returns `5xx`/`429`/connect error *before* any - When the primary upstream returns `5xx`/`429`/connect error *before* any
stream bytes are flushed, `olpx` transparently retries the next target. stream bytes are flushed, `ocswitch` transparently retries the next target.
- Once a stream has started, the upstream is locked for the rest of that - Once a stream has started, the upstream is locked for the rest of that
request — no mid-stream splicing. request — no mid-stream splicing.
@ -18,38 +18,38 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
## Install ## Install
```bash ```bash
go build -o olpx ./cmd/olpx go build -o ocswitch ./cmd/ocswitch
``` ```
## Quick start ## Quick start
```bash ```bash
# 1. add upstream providers # 1. add upstream providers
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-... ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
olpx provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-... ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-...
# 2. create alias and bind targets in priority order # 2. create alias and bind targets in priority order
olpx alias add --name gpt-5.4 ocswitch alias add --name gpt-5.4
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
# 3. push alias exposure into OpenCode global config # 3. push alias exposure into OpenCode global config
olpx opencode sync ocswitch opencode sync
# optional: temporarily disable one provider without editing alias targets # optional: temporarily disable one provider without editing alias targets
olpx provider disable su8 ocswitch provider disable su8
# 4. run the proxy # 4. run the proxy
olpx serve ocswitch serve
``` ```
Inside OpenCode you can now pick `olpx/gpt-5.4`. Inside OpenCode you can now pick `ocswitch/gpt-5.4`.
### Import providers from an existing OpenCode config ### Import providers from an existing OpenCode config
```bash ```bash
olpx provider import-opencode # reads global OpenCode config ocswitch provider import-opencode # reads global OpenCode config
olpx provider import-opencode --from ./examples/opencode.jsonc ocswitch provider import-opencode --from ./examples/opencode.jsonc
``` ```
The default import/sync target is the global user config only. It does not The default import/sync target is the global user config only. It does not
@ -62,7 +62,7 @@ imported. Everything else is out of MVP scope.
### Doctor (static) ### Doctor (static)
```bash ```bash
olpx doctor ocswitch doctor
``` ```
Runs structural checks only — never issues real upstream requests. Runs structural checks only — never issues real upstream requests.
@ -75,14 +75,14 @@ considered routable only when:
- the referenced provider exists - the referenced provider exists
- the referenced provider is not disabled - the referenced provider is not disabled
`olpx opencode sync` and `/v1/models` use the same routable-alias view, so `ocswitch opencode sync` and `/v1/models` use the same routable-alias view, so
OpenCode does not see aliases that the proxy would immediately reject. OpenCode does not see aliases that the proxy would immediately reject.
### Provider state ### Provider state
```bash ```bash
olpx provider disable <id> ocswitch provider disable <id>
olpx provider enable <id> ocswitch provider enable <id>
``` ```
Disabling a provider only removes it from routing/failover consideration. It Disabling a provider only removes it from routing/failover consideration. It
@ -95,23 +95,23 @@ 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 matching `--help` page. This README is the quick-start narrative, while CLI help
is the authoritative local execution contract. is the authoritative local execution contract.
- `olpx serve` — run the proxy - `ocswitch serve` — run the proxy
- `olpx doctor` — validate config - `ocswitch doctor` — validate config
- `olpx provider {add,list,enable,disable,remove,import-opencode}` - `ocswitch provider {add,list,enable,disable,remove,import-opencode}`
- `olpx alias {add,list,bind,unbind,remove}` - `ocswitch alias {add,list,bind,unbind,remove}`
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]` - `ocswitch opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
Global flag: `--config PATH` (default `$XDG_CONFIG_HOME/olpx/config.json`). Global flag: `--config PATH` (default `$OCSWITCH_CONFIG`, else `$XDG_CONFIG_HOME/ocswitch/config.json`).
## Debug headers ## Debug headers
Every proxied response includes: Every proxied response includes:
- `X-OLPX-Alias` - `X-OCSWITCH-Alias`
- `X-OLPX-Provider` - `X-OCSWITCH-Provider`
- `X-OLPX-Remote-Model` - `X-OCSWITCH-Remote-Model`
- `X-OLPX-Attempt` - `X-OCSWITCH-Attempt`
- `X-OLPX-Failover-Count` - `X-OCSWITCH-Failover-Count`
## Scope ## Scope

View File

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

View File

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

View File

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

2
go.mod
View File

@ -1,4 +1,4 @@
module github.com/anomalyco/opencode-provider-switch module github.com/Apale7/opencode-provider-switch
go 1.22.2 go 1.22.2

View File

@ -7,26 +7,26 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
func newAliasCmd() *cobra.Command { func newAliasCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "alias", Use: "alias",
Short: "Manage logical aliases routed by olpx", Short: "Manage logical aliases routed by ocswitch",
Long: `Alias commands manage the user-facing model names that OpenCode sees as Long: `Alias commands manage the user-facing model names that OpenCode sees as
olpx/<alias>. ocswitch/<alias>.
Each alias contains an ordered target chain of provider/model pairs. Target Each alias contains an ordered target chain of provider/model pairs. Target
order is operational: olpx tries targets in order and only fails over before any order is operational: ocswitch tries targets in order and only fails over before any
response bytes are sent downstream. response bytes are sent downstream.
Common workflow: create an alias, bind primary and fallback targets, inspect the Common workflow: create an alias, bind primary and fallback targets, inspect the
result with alias list, then run doctor and opencode sync.`, result with alias list, then run doctor and opencode sync.`,
Example: ` olpx alias add --name gpt-5.4 --display-name "GPT 5.4" Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx alias list`, ocswitch alias list`,
} }
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd()) c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
return c return c
@ -38,7 +38,7 @@ func newAliasAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add", Use: "add",
Short: "Create or update an alias (without targets)", Short: "Create or update an alias (without targets)",
Long: `alias add creates or updates alias metadata in local olpx config. 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. 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 Enabled aliases still need at least one routable target before doctor and
@ -46,10 +46,10 @@ opencode sync will treat them as usable.
When updating an existing alias, omitted display-name preserves the current When updating an existing alias, omitted display-name preserves the current
value and existing targets stay attached. Typical next step: add targets with value and existing targets stay attached. Typical next step: add targets with
olpx alias bind.`, ocswitch alias bind.`,
Example: ` olpx alias add --name gpt-5.4 --display-name "GPT 5.4" Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
olpx alias add --name gpt-5.4-mini --disabled ocswitch alias add --name gpt-5.4-mini --disabled
olpx alias add --name gpt-5.4 --display-name "GPT 5.4 Reasoning"`, ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4 Reasoning"`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if name == "" { if name == "" {
return fmt.Errorf("--name is required") return fmt.Errorf("--name is required")
@ -78,7 +78,7 @@ olpx alias bind.`,
return nil return nil
}, },
} }
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as olpx/<name> in OpenCode (required)") cmd.Flags().StringVar(&name, "name", "", "alias name exposed as ocswitch/<name> in OpenCode (required)")
cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name") cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name")
cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state") cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state")
return cmd return cmd
@ -88,7 +88,7 @@ func newAliasListCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List aliases and their target chains", Short: "List aliases and their target chains",
Long: `alias list prints aliases from local olpx config together with their target Long: `alias list prints aliases from local ocswitch config together with their target
chains. chains.
Output shows alias enabled state, target order, target enabled markers, and a Output shows alias enabled state, target order, target enabled markers, and a
@ -96,8 +96,8 @@ note when a referenced provider is missing or disabled. This is the easiest way
to verify failover order before running doctor or opencode sync. to verify failover order before running doctor or opencode sync.
This command does not modify config and does not contact upstream providers.`, This command does not modify config and does not contact upstream providers.`,
Example: ` olpx alias list Example: ` ocswitch alias list
olpx --config /path/to/config.json alias list`, ocswitch --config /path/to/config.json alias list`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -143,7 +143,7 @@ func newAliasBindCmd() *cobra.Command {
Use: "bind", Use: "bind",
Short: "Append a target (provider/model) to an alias in failover order", Short: "Append a target (provider/model) to an alias in failover order",
Long: `alias bind appends one provider/model target to an alias's ordered failover Long: `alias bind appends one provider/model target to an alias's ordered failover
chain in local olpx config. chain in local ocswitch config.
The provider must already exist. If the alias does not exist yet, this command The provider must already exist. If the alias does not exist yet, this command
auto-creates an enabled alias for convenience. Binding does not test upstream auto-creates an enabled alias for convenience. Binding does not test upstream
@ -151,9 +151,9 @@ health or credentials.
Order matters: the first bound target is tried first, the second is fallback, 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.`, and so on. Typical next step: inspect with alias list, then run doctor.`,
Example: ` olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 Example: ` ocswitch 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 bind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx alias bind --alias gpt-5.4 --provider relay --model gpt-5.4 --disabled`, ocswitch alias bind --alias gpt-5.4 --provider relay --model gpt-5.4 --disabled`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" { if alias == "" || provider == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required") return fmt.Errorf("--alias, --provider and --model are required")
@ -192,7 +192,7 @@ func newAliasUnbindCmd() *cobra.Command {
Use: "unbind", Use: "unbind",
Short: "Remove a target from an alias", Short: "Remove a target from an alias",
Long: `alias unbind removes one concrete provider/model target tuple from an alias in Long: `alias unbind removes one concrete provider/model target tuple from an alias in
local olpx config. local ocswitch config.
It does not delete the alias itself. Removing a target can leave the alias with 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 no routable targets, which doctor and opencode sync will then treat as invalid
@ -200,8 +200,8 @@ or unavailable.
Typical next step: run alias list or doctor to confirm the remaining target Typical next step: run alias list or doctor to confirm the remaining target
chain.`, chain.`,
Example: ` olpx alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4 Example: ` ocswitch alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx doctor`, ocswitch doctor`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" { if alias == "" || provider == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required") return fmt.Errorf("--alias, --provider and --model are required")
@ -231,16 +231,16 @@ func newAliasRemoveCmd() *cobra.Command {
Use: "remove <alias>", Use: "remove <alias>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: "Delete an alias entirely", Short: "Delete an alias entirely",
Long: `alias remove deletes one alias and all of its target bindings from local olpx Long: `alias remove deletes one alias and all of its target bindings from local ocswitch
config. config.
Future opencode sync runs will stop exposing that alias in provider.olpx.models. 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 This command does not directly clear top-level model selections that may still
reference the old alias in OpenCode config. reference the old alias in OpenCode config.
Typical next step: run olpx opencode sync if OpenCode exposure should be updated.`, Typical next step: run ocswitch opencode sync if OpenCode exposure should be updated.`,
Example: ` olpx alias remove gpt-5.4 Example: ` ocswitch alias remove gpt-5.4
olpx opencode sync`, ocswitch opencode sync`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {

View File

@ -9,11 +9,11 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
func TestProviderAddPreservesExistingFields(t *testing.T) { func TestProviderAddPreservesExistingFields(t *testing.T) {
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json")) t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json"))
configPath = "" configPath = ""
cfg, err := loadCfg() cfg, err := loadCfg()
@ -64,8 +64,8 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
} }
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) { func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "olpx.json") configFile := filepath.Join(t.TempDir(), "ocswitch.json")
t.Setenv("OLPX_CONFIG", configFile) t.Setenv(config.ConfigEnvVar, configFile)
configPath = "" configPath = ""
cmd := newProviderAddCmd() cmd := newProviderAddCmd()
@ -83,7 +83,7 @@ func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
} }
func TestAliasAddPreservesExistingFields(t *testing.T) { func TestAliasAddPreservesExistingFields(t *testing.T) {
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json")) t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json"))
configPath = "" configPath = ""
cfg, err := loadCfg() cfg, err := loadCfg()
@ -126,7 +126,7 @@ func TestAliasAddPreservesExistingFields(t *testing.T) {
} }
func TestProviderEnableDisableCommands(t *testing.T) { func TestProviderEnableDisableCommands(t *testing.T) {
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json")) t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json"))
configPath = "" configPath = ""
cfg, err := loadCfg() cfg, err := loadCfg()
@ -171,7 +171,7 @@ func TestProviderEnableDisableCommands(t *testing.T) {
} }
func TestOpencodeSyncDoesNotPanicOnSliceModelMetadata(t *testing.T) { func TestOpencodeSyncDoesNotPanicOnSliceModelMetadata(t *testing.T) {
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json")) t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json"))
configPath = "" configPath = ""
cfg, err := loadCfg() cfg, err := loadCfg()
@ -192,7 +192,7 @@ func TestOpencodeSyncDoesNotPanicOnSliceModelMetadata(t *testing.T) {
} }
target := filepath.Join(t.TempDir(), "opencode.jsonc") target := 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 \"tags\": [\"reasoning\", \"priority\"],\n \"variants\": [\n {\"name\": \"high\", \"effort\": \"high\"}\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 \"tags\": [\"reasoning\", \"priority\"],\n \"variants\": [\n {\"name\": \"high\", \"effort\": \"high\"}\n ]\n }\n }\n }\n }\n}\n")
if err := os.WriteFile(target, seed, 0o600); err != nil { if err := os.WriteFile(target, seed, 0o600); err != nil {
t.Fatalf("write target config: %v", err) t.Fatalf("write target config: %v", err)
} }
@ -235,13 +235,13 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) {
name: "root", name: "root",
cmd: NewRootCmd("test"), cmd: NewRootCmd("test"),
wantLong: []string{"Typical workflow:", "prefer command-local --help over README summaries"}, wantLong: []string{"Typical workflow:", "prefer command-local --help over README summaries"},
wantExample: []string{"olpx provider add", "olpx serve"}, wantExample: []string{"ocswitch provider add", "ocswitch serve"},
}, },
{ {
name: "provider add", name: "provider add",
cmd: newProviderAddCmd(), cmd: newProviderAddCmd(),
wantLong: []string{"--base-url must point at an", "omitted mutable fields keep their current", "values: name, api key, headers"}, wantLong: []string{"--base-url must point at an", "omitted mutable fields keep their current", "values: name, api key, headers"},
wantExample: []string{"olpx provider add --id relay", "--header X-Workspace=my-team"}, wantExample: []string{"ocswitch provider add --id relay", "--header X-Workspace=my-team"},
}, },
{ {
name: "alias bind", name: "alias bind",
@ -252,8 +252,8 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) {
{ {
name: "opencode sync", name: "opencode sync",
cmd: newOpencodeSyncCmd(), cmd: newOpencodeSyncCmd(),
wantLong: []string{"does not follow", "writes alias", "exposure into provider.olpx.models"}, wantLong: []string{"does not follow", "writes alias", "exposure into provider.ocswitch.models"},
wantExample: []string{"--dry-run", "--set-small-model olpx/gpt-5.4-mini"}, wantExample: []string{"--dry-run", "--set-small-model ocswitch/gpt-5.4-mini"},
}, },
} }
@ -283,7 +283,7 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) {
if flag == nil { if flag == nil {
t.Fatal("--config flag not found") t.Fatal("--config flag not found")
} }
for _, want := range []string{"$OLPX_CONFIG", "$XDG_CONFIG_HOME/olpx/config.json", "~/.config/olpx/config.json"} { for _, want := range []string{"$OCSWITCH_CONFIG", "$XDG_CONFIG_HOME/ocswitch/config.json", "~/.config/ocswitch/config.json"} {
if !strings.Contains(flag.Usage, want) { if !strings.Contains(flag.Usage, want) {
t.Fatalf("config flag usage missing %q: %s", want, flag.Usage) t.Fatalf("config flag usage missing %q: %s", want, flag.Usage)
} }

View File

@ -5,25 +5,25 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/opencode" "github.com/Apale7/opencode-provider-switch/internal/opencode"
) )
func newDoctorCmd() *cobra.Command { func newDoctorCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "doctor", Use: "doctor",
Short: "Validate olpx config (static checks, no upstream requests)", Short: "Validate ocswitch config (static checks, no upstream requests)",
Long: `doctor performs static validation for local olpx config and the expected Long: `doctor performs static validation for local ocswitch config and the expected
OpenCode sync result. OpenCode sync result.
It loads local olpx config, checks alias/provider consistency, resolves the It loads local ocswitch config, checks alias/provider consistency, resolves the
default OpenCode sync target, and validates what provider.olpx would look like 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 there. It does not send any real requests to upstream providers and does not
consume model quota. consume model quota.
Run doctor before opencode sync or serve whenever you changed providers, Run doctor before opencode sync or serve whenever you changed providers,
aliases, or local server settings.`, aliases, or local server settings.`,
Example: ` olpx doctor Example: ` ocswitch doctor
olpx --config /path/to/config.json doctor`, ocswitch --config /path/to/config.json doctor`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -38,9 +38,9 @@ aliases, or local server settings.`,
} else { } else {
aliasNames := cfg.AvailableAliasNames() aliasNames := cfg.AvailableAliasNames()
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil { if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.olpx invalid: %w", err)) issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err))
} }
} }
ok := len(issues) == 0 ok := len(issues) == 0
@ -61,7 +61,7 @@ aliases, or local server settings.`,
marker = "(exists)" marker = "(exists)"
} }
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker) fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.olpx preview: valid=%v\n", ok) fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", ok)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port) fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
if !ok { if !ok {

View File

@ -5,24 +5,24 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/opencode" "github.com/Apale7/opencode-provider-switch/internal/opencode"
) )
func newOpencodeCmd() *cobra.Command { func newOpencodeCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "opencode", Use: "opencode",
Short: "OpenCode integration commands", Short: "OpenCode integration commands",
Long: `OpenCode commands manage the narrow integration boundary between olpx and Long: `OpenCode commands manage the narrow integration boundary between ocswitch and
OpenCode config. OpenCode config.
These commands do not attempt full OpenCode config takeover. They are limited to These commands do not attempt full OpenCode config takeover. They are limited to
the provider.olpx sync path and optional top-level model fields when you opt in the provider.ocswitch sync path and optional top-level model fields when you opt in
explicitly. explicitly.
Common workflow: validate with doctor first, inspect sync help, then run Common workflow: validate with doctor first, inspect sync help, then run
opencode sync.`, opencode sync.`,
Example: ` olpx opencode sync --dry-run Example: ` ocswitch opencode sync --dry-run
olpx opencode sync --set-model olpx/gpt-5.4`, ocswitch opencode sync --set-model ocswitch/gpt-5.4`,
} }
c.AddCommand(newOpencodeSyncCmd()) c.AddCommand(newOpencodeSyncCmd())
return c return c
@ -35,8 +35,8 @@ func newOpencodeSyncCmd() *cobra.Command {
var dryRun bool var dryRun bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "sync", Use: "sync",
Short: "Update provider.olpx in the global OpenCode config to match current aliases", Short: "Update provider.ocswitch in the global OpenCode config to match current aliases",
Long: `olpx opencode sync writes provider.olpx into the target OpenCode config. Long: `ocswitch opencode sync writes provider.ocswitch into the target OpenCode config.
By default it targets the global user config (~/.config/opencode), picking the By default it targets the global user config (~/.config/opencode), picking the
existing file in precedence order opencode.jsonc > opencode.json > config.json, existing file in precedence order opencode.jsonc > opencode.json > config.json,
@ -45,17 +45,17 @@ or creating opencode.jsonc if none exists. It does NOT touch the top-level
The default target scope is only the global user config path; it does not follow 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 OPENCODE_CONFIG_DIR unless you pass --target yourself. The command writes alias
exposure into provider.olpx.models using only aliases that are currently exposure into provider.ocswitch.models using only aliases that are currently
routable. routable.
Use --dry-run to preview the resolved target file without writing it. Typical Use --dry-run to preview the resolved target file without writing it. Typical
workflow: run olpx doctor first, then sync, then start or restart olpx serve if workflow: run ocswitch doctor first, then sync, then start or restart ocswitch serve if
needed.`, needed.`,
Example: ` olpx opencode sync Example: ` ocswitch opencode sync
olpx opencode sync --dry-run ocswitch opencode sync --dry-run
olpx opencode sync --set-model olpx/gpt-5.4 ocswitch opencode sync --set-model ocswitch/gpt-5.4
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
olpx opencode sync --target /path/to/opencode.jsonc`, ocswitch opencode sync --target /path/to/opencode.jsonc`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -78,7 +78,7 @@ needed.`,
} }
aliasNames := cfg.AvailableAliasNames() aliasNames := cfg.AvailableAliasNames()
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if setModel != "" { if setModel != "" {
if raw["model"] != setModel { if raw["model"] != setModel {
raw["model"] = setModel raw["model"] = setModel
@ -102,7 +102,7 @@ needed.`,
if err := opencode.Save(path, raw); err != nil { if err := opencode.Save(path, raw); err != nil {
return err 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", path, len(aliasNames))
if setModel != "" { if setModel != "" {
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel) fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
} }

View File

@ -7,8 +7,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/anomalyco/opencode-provider-switch/internal/opencode" "github.com/Apale7/opencode-provider-switch/internal/opencode"
) )
func newProviderCmd() *cobra.Command { func newProviderCmd() *cobra.Command {
@ -16,17 +16,17 @@ func newProviderCmd() *cobra.Command {
Use: "provider", Use: "provider",
Short: "Manage upstream providers", Short: "Manage upstream providers",
Long: `Provider commands manage upstream OpenAI-compatible endpoints stored in the Long: `Provider commands manage upstream OpenAI-compatible endpoints stored in the
local olpx config file. local ocswitch config file.
Providers are separate from aliases: a provider defines connection details such 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 as base URL, API key, and extra headers, while aliases decide failover order by
binding one or more provider/model targets. binding one or more provider/model targets.
Common workflow: add or import providers first, inspect them with provider list, Common workflow: add or import providers first, inspect them with provider list,
then bind them to aliases with olpx alias bind.`, then bind them to aliases with ocswitch alias bind.`,
Example: ` olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
olpx provider import-opencode ocswitch provider import-opencode
olpx provider list`, ocswitch provider list`,
} }
c.AddCommand( c.AddCommand(
newProviderAddCmd(), newProviderAddCmd(),
@ -46,10 +46,10 @@ func newProviderAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add", Use: "add",
Short: "Add or update an upstream provider", Short: "Add or update an upstream provider",
Long: `provider add creates or updates one upstream provider entry in local olpx Long: `provider add creates or updates one upstream provider entry in local ocswitch
config. config.
It writes only the olpx config file. --base-url must point at an It writes only the ocswitch config file. --base-url must point at an
OpenAI-compatible /v1 root. The command validates that shape, but it does not OpenAI-compatible /v1 root. The command validates that shape, but it does not
contact the upstream or test credentials. contact the upstream or test credentials.
@ -58,11 +58,11 @@ values: name, api key, headers, and disabled state are preserved unless you
explicitly pass new values. Repeated --header KEY=VALUE entries replace the explicitly pass new values. Repeated --header KEY=VALUE entries replace the
stored header map for this command invocation. stored header map for this command invocation.
Typical next step: run olpx provider list or bind the provider to an alias.`, Typical next step: run ocswitch provider list or bind the provider to an alias.`,
Example: ` olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
olpx 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 --api-key sk-example --header X-Token=abc --header X-Workspace=my-team
olpx provider add --id su8 --base-url https://new.example.com/v1`, ocswitch provider add --id su8 --base-url https://new.example.com/v1`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if id == "" || baseURL == "" { if id == "" || baseURL == "" {
return fmt.Errorf("--id and --base-url are required") return fmt.Errorf("--id and --base-url are required")
@ -140,15 +140,15 @@ func newProviderListCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List configured providers", Short: "List configured providers",
Long: `provider list prints the providers currently stored in local olpx config. Long: `provider list prints the providers currently stored in local ocswitch config.
Output is inspection-oriented: provider ids, enabled state, base URLs, and 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 redacted API keys are shown so you can confirm what was saved or imported before
binding aliases. binding aliases.
This command does not modify config and does not contact upstream providers.`, This command does not modify config and does not contact upstream providers.`,
Example: ` olpx provider list Example: ` ocswitch provider list
olpx --config /path/to/config.json provider list`, ocswitch --config /path/to/config.json provider list`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -193,16 +193,16 @@ func newProviderStateCmd(use string, disabled bool) *cobra.Command {
Use: use + " <id>", Use: use + " <id>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state", Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state",
Long: fmt.Sprintf(`provider %s flips one provider's disabled state in local olpx config. 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 It changes routing eligibility for every alias target that references this
provider, but it does not rewrite alias target enabled flags. This matters when provider, but it does not rewrite alias target enabled flags. This matters when
the same provider is shared across multiple aliases. the same provider is shared across multiple aliases.
This command writes only the olpx config file and does not test upstream This command writes only the ocswitch config file and does not test upstream
reachability. Typical next step: run olpx doctor to confirm routable aliases.`, use), reachability. Typical next step: run ocswitch doctor to confirm routable aliases.`, use),
Example: fmt.Sprintf(` olpx provider %s <id> Example: fmt.Sprintf(` ocswitch provider %s <id>
olpx doctor`, use), ocswitch doctor`, use),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -229,16 +229,16 @@ func newProviderRemoveCmd() *cobra.Command {
Use: "remove <id>", Use: "remove <id>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)", Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)",
Long: `provider remove deletes one provider from local olpx config. Long: `provider remove deletes one provider from local ocswitch config.
It does not automatically clean alias target references that still point at the It does not automatically clean alias target references that still point at the
removed provider. If aliases still reference it, doctor will report invalid removed provider. If aliases still reference it, doctor will report invalid
config and those aliases will not be routable. config and those aliases will not be routable.
Typical follow-up: inspect aliases, unbind stale targets, then run olpx doctor.`, Typical follow-up: inspect aliases, unbind stale targets, then run ocswitch doctor.`,
Example: ` olpx provider remove su8 Example: ` ocswitch provider remove su8
olpx alias list ocswitch alias list
olpx doctor`, ocswitch doctor`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -263,7 +263,7 @@ func newProviderImportCmd() *cobra.Command {
Use: "import-opencode", Use: "import-opencode",
Short: "Import @ai-sdk/openai custom providers from an OpenCode config file", Short: "Import @ai-sdk/openai custom providers from an OpenCode config file",
Long: `provider import-opencode reads an OpenCode config file and copies supported Long: `provider import-opencode reads an OpenCode config file and copies supported
custom providers into local olpx config. custom providers into local ocswitch config.
By default it reads the global user OpenCode config resolved in precedence order By default it reads the global user OpenCode config resolved in precedence order
opencode.jsonc > opencode.json > config.json under ~/.config/opencode (XDG opencode.jsonc > opencode.json > config.json under ~/.config/opencode (XDG
@ -271,13 +271,13 @@ aware). It does not follow OPENCODE_CONFIG_DIR for this default source; use
--from when you want a different file. --from when you want a different file.
Only config-defined @ai-sdk/openai custom providers with both baseURL and apiKey Only config-defined @ai-sdk/openai custom providers with both baseURL and apiKey
are imported. Unsupported provider shapes are skipped by design. Existing olpx are imported. Unsupported provider shapes are skipped by design. Existing ocswitch
providers are skipped unless --overwrite is given. providers are skipped unless --overwrite is given.
Typical next step: run olpx provider list, then create aliases and bindings.`, Typical next step: run ocswitch provider list, then create aliases and bindings.`,
Example: ` olpx provider import-opencode Example: ` ocswitch provider import-opencode
olpx provider import-opencode --from /path/to/opencode.jsonc ocswitch provider import-opencode --from /path/to/opencode.jsonc
olpx provider import-opencode --overwrite`, ocswitch provider import-opencode --overwrite`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if srcPath == "" { if srcPath == "" {
p, existed := opencode.ResolveGlobalConfigPath() p, existed := opencode.ResolveGlobalConfigPath()

View File

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

View File

@ -6,24 +6,24 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/proxy" "github.com/Apale7/opencode-provider-switch/internal/proxy"
) )
func newServeCmd() *cobra.Command { func newServeCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "serve", Use: "serve",
Short: "Run the local olpx proxy (alias -> failover upstream)", Short: "Run the local ocswitch proxy (alias -> failover upstream)",
Long: `serve starts the long-running local olpx proxy using the current local config. 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 It reads local alias/provider configuration, validates that config, and then
accepts OpenAI Responses traffic at the configured local base URL. With default 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 settings the proxy listens on http://127.0.0.1:9982/v1 and expects the local API
key olpx-local. key ocswitch-local.
serve does not rewrite config files. Run doctor and opencode sync first so serve does not rewrite config files. Run doctor and opencode sync first so
OpenCode can see the same aliases that the proxy can route.`, OpenCode can see the same aliases that the proxy can route.`,
Example: ` olpx serve Example: ` ocswitch serve
olpx --config /path/to/config.json serve`, ocswitch --config /path/to/config.json serve`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {

View File

@ -1,4 +1,4 @@
// Package config manages the local olpx JSON config file. // Package config manages the local ocswitch JSON config file.
package config package config
import ( import (
@ -12,6 +12,13 @@ import (
"sync" "sync"
) )
const (
AppName = "ocswitch"
ConfigEnvVar = "OCSWITCH_CONFIG"
ConfigDirName = "ocswitch"
DefaultLocalAPIKey = "ocswitch-local"
)
// Target is one concrete upstream candidate for an alias. // Target is one concrete upstream candidate for an alias.
type Target struct { type Target struct {
Provider string `json:"provider"` Provider string `json:"provider"`
@ -44,7 +51,7 @@ type Server struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
} }
// Config is the on-disk olpx config. // Config is the on-disk ocswitch config.
type Config struct { type Config struct {
Server Server `json:"server"` Server Server `json:"server"`
Providers []Provider `json:"providers"` Providers []Provider `json:"providers"`
@ -78,26 +85,26 @@ func Default() *Config {
Server: Server{ Server: Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 9982, Port: 9982,
APIKey: "olpx-local", APIKey: DefaultLocalAPIKey,
}, },
Providers: []Provider{}, Providers: []Provider{},
Aliases: []Alias{}, Aliases: []Alias{},
} }
} }
// DefaultPath returns ~/.config/olpx/config.json (XDG aware). // DefaultPath returns ~/.config/ocswitch/config.json (XDG aware).
func DefaultPath() string { func DefaultPath() string {
if p := os.Getenv("OLPX_CONFIG"); p != "" { if p := os.Getenv(ConfigEnvVar); p != "" {
return p return p
} }
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "olpx", "config.json") return filepath.Join(xdg, ConfigDirName, "config.json")
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "olpx-config.json" return AppName + "-config.json"
} }
return filepath.Join(home, ".config", "olpx", "config.json") return filepath.Join(home, ".config", ConfigDirName, "config.json")
} }
// Load reads the config at path. Missing file returns a default config anchored to path. // Load reads the config at path. Missing file returns a default config anchored to path.
@ -127,7 +134,7 @@ func Load(path string) (*Config, error) {
c.Server.Port = 9982 c.Server.Port = 9982
} }
if c.Server.APIKey == "" { if c.Server.APIKey == "" {
c.Server.APIKey = "olpx-local" c.Server.APIKey = DefaultLocalAPIKey
} }
c.path = path c.path = path
return c, nil return c, nil

View File

@ -93,7 +93,7 @@ func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) {
t.Parallel() t.Parallel()
cfg := &Config{ cfg := &Config{
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: "olpx-local"}, Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey},
Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", Disabled: true}}, Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", Disabled: true}},
Aliases: []Alias{{ Aliases: []Alias{{
Alias: "gpt-5.4", Alias: "gpt-5.4",

View File

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

View File

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

View File

@ -1,5 +1,5 @@
// Package proxy implements the local `/v1/responses` HTTP server that resolves // Package proxy implements the local `/v1/responses` HTTP server that resolves
// olpx aliases and forwards requests to upstream providers with deterministic // ocswitch aliases and forwards requests to upstream providers with deterministic
// pre-first-byte failover. // pre-first-byte failover.
package proxy package proxy
@ -17,7 +17,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
var firstByteTimeout = 15 * time.Second var firstByteTimeout = 15 * time.Second
@ -32,7 +32,7 @@ type openAIError struct {
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
} }
// Server is the local olpx HTTP proxy. // Server is the local ocswitch HTTP proxy.
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
client *http.Client client *http.Client
@ -62,7 +62,7 @@ func New(cfg *config.Config) *Server {
Transport: transport, Transport: transport,
Timeout: 0, // streaming, no overall timeout Timeout: 0, // streaming, no overall timeout
}, },
logger: log.New(log.Writer(), "[olpx] ", log.LstdFlags|log.Lmicroseconds), logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
} }
} }
@ -109,7 +109,7 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
data = append(data, map[string]any{ data = append(data, map[string]any{
"id": aliasName, "id": aliasName,
"object": "model", "object": "model",
"owned_by": "olpx", "owned_by": config.AppName,
}) })
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -371,7 +371,7 @@ func errorTypeForStatus(status int) string {
} }
func normalizeAliasName(model string) string { func normalizeAliasName(model string) string {
const prefix = "olpx/" prefix := config.AppName + "/"
if strings.HasPrefix(model, prefix) { if strings.HasPrefix(model, prefix) {
trimmed := strings.TrimPrefix(model, prefix) trimmed := strings.TrimPrefix(model, prefix)
if trimmed != "" { if trimmed != "" {
@ -381,14 +381,14 @@ func normalizeAliasName(model string) string {
return model return model
} }
// writeDebugHeaders sets the X-OLPX-* debug headers before WriteHeader. // writeDebugHeaders sets the X-OCSWITCH-* debug headers before WriteHeader.
func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) { func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) {
h := w.Header() h := w.Header()
h.Set("X-OLPX-Alias", alias) h.Set("X-OCSWITCH-Alias", alias)
h.Set("X-OLPX-Provider", provider) h.Set("X-OCSWITCH-Provider", provider)
h.Set("X-OLPX-Remote-Model", remoteModel) h.Set("X-OCSWITCH-Remote-Model", remoteModel)
h.Set("X-OLPX-Attempt", fmt.Sprintf("%d", attempt)) h.Set("X-OCSWITCH-Attempt", fmt.Sprintf("%d", attempt))
h.Set("X-OLPX-Failover-Count", fmt.Sprintf("%d", failoverCount)) h.Set("X-OCSWITCH-Failover-Count", fmt.Sprintf("%d", failoverCount))
} }
// hopByHopHeaders lists headers that must not be forwarded per RFC 7230. // hopByHopHeaders lists headers that must not be forwarded per RFC 7230.

View File

@ -11,17 +11,17 @@ import (
"testing" "testing"
"time" "time"
"github.com/anomalyco/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) { func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
t.Parallel() t.Parallel()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -64,7 +64,7 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
defer second.Close() defer second.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"}, {ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"},
{ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"}, {ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"},
@ -76,8 +76,8 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
}}, }},
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"olpx/gpt-5.4","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"ocswitch/gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream") req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -96,14 +96,14 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
if secondSeenModel != "up-2" { if secondSeenModel != "up-2" {
t.Fatalf("second upstream model = %q, want up-2", secondSeenModel) t.Fatalf("second upstream model = %q, want up-2", secondSeenModel)
} }
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" { if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "2" {
t.Fatalf("X-OLPX-Attempt = %q, want 2", got) t.Fatalf("X-OCSWITCH-Attempt = %q, want 2", got)
} }
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" { if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "1" {
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got) t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 1", got)
} }
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
t.Fatalf("X-OLPX-Provider = %q, want p2", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
} }
} }
@ -124,7 +124,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
defer second.Close() defer second.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1"}, {ID: "p1", BaseURL: first.URL + "/v1"},
{ID: "p2", BaseURL: second.URL + "/v1"}, {ID: "p2", BaseURL: second.URL + "/v1"},
@ -137,7 +137,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -149,8 +149,8 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
if calledSecond { if calledSecond {
t.Fatal("second upstream should not be called for 400 response") t.Fatal("second upstream should not be called for 400 response")
} }
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p1" {
t.Fatalf("X-OLPX-Provider = %q, want p1", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p1", got)
} }
if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` { if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` {
t.Fatalf("body = %q", body) t.Fatalf("body = %q", body)
@ -181,7 +181,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
defer enabled.Close() defer enabled.Close()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true}, {ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true},
{ID: "p2", BaseURL: enabled.URL + "/v1"}, {ID: "p2", BaseURL: enabled.URL + "/v1"},
@ -194,7 +194,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream") req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -210,14 +210,14 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
if seenModel != "up-2" { if seenModel != "up-2" {
t.Fatalf("enabled upstream model = %q, want up-2", seenModel) t.Fatalf("enabled upstream model = %q, want up-2", seenModel)
} }
if got := rr.Header().Get("X-OLPX-Attempt"); got != "1" { if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "1" {
t.Fatalf("X-OLPX-Attempt = %q, want 1", got) t.Fatalf("X-OCSWITCH-Attempt = %q, want 1", got)
} }
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "0" { if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "0" {
t.Fatalf("X-OLPX-Failover-Count = %q, want 0", got) t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 0", got)
} }
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
t.Fatalf("X-OLPX-Provider = %q, want p2", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
} }
if body := rr.Body.String(); body != "data: ok\n\n" { if body := rr.Body.String(); body != "data: ok\n\n" {
t.Fatalf("body = %q, want SSE payload", body) t.Fatalf("body = %q, want SSE payload", body)
@ -228,7 +228,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
t.Parallel() t.Parallel()
srv := New(&config.Config{ srv := New(&config.Config{
Server: config.Server{APIKey: "olpx-local"}, Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{ Providers: []config.Provider{
{ID: "p1", BaseURL: "https://p1.example.com/v1"}, {ID: "p1", BaseURL: "https://p1.example.com/v1"},
{ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true}, {ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true},
@ -241,7 +241,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer olpx-local") req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
srv.handleModels(rr, req) srv.handleModels(rr, req)