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:
parent
594ecadfdd
commit
cf3dcec399
@ -1,8 +1,8 @@
|
||||
# OLPX Forwarding Log Viewer Design
|
||||
# OCSWITCH Forwarding Log Viewer Design
|
||||
|
||||
## Summary
|
||||
|
||||
`olpx` currently exposes enough per-request routing information for header-level debugging, but it does not keep a durable request log and it does not provide any local UI.
|
||||
`ocswitch` currently exposes enough per-request routing information for header-level debugging, but it does not keep a durable request log and it does not provide any local UI.
|
||||
|
||||
This task defines a minimal, implementation-ready design for a local web log viewer that shows each forwarded request with:
|
||||
|
||||
@ -23,18 +23,18 @@ The design is intentionally scoped to the current codebase:
|
||||
|
||||
### What already exists
|
||||
|
||||
`olpx` already has the core proxy path in `internal/proxy/server.go`:
|
||||
`ocswitch` already has the core proxy path in `internal/proxy/server.go`:
|
||||
|
||||
- `POST /v1/responses`
|
||||
- deterministic ordered failover
|
||||
- retry on transport errors, `429`, and `5xx`
|
||||
- no mid-stream failover after downstream response starts
|
||||
- debug response headers:
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
### What is missing
|
||||
|
||||
@ -74,12 +74,12 @@ Reason:
|
||||
|
||||
- it fits the current Go-only codebase
|
||||
- it avoids adding a frontend toolchain
|
||||
- it can run off the same `olpx serve` process and listener
|
||||
- it can run off the same `ocswitch serve` process and listener
|
||||
- it is the smallest path to a usable visual log surface
|
||||
|
||||
## High-Level Design
|
||||
|
||||
When `olpx serve` is running, the same HTTP server should expose three surfaces:
|
||||
When `ocswitch serve` is running, the same HTTP server should expose three surfaces:
|
||||
|
||||
1. proxy API
|
||||
- `POST /v1/responses`
|
||||
@ -116,10 +116,10 @@ This keeps storage simple and makes the UI directly match the user's mental mode
|
||||
|
||||
### Recommended MVP storage
|
||||
|
||||
Use an append-only JSONL file stored next to the existing `olpx` config:
|
||||
Use an append-only JSONL file stored next to the existing `ocswitch` config:
|
||||
|
||||
- config path today: `~/.config/olpx/config.json` by default
|
||||
- proposed log path: `~/.config/olpx/request-log.jsonl`
|
||||
- config path today: `~/.config/ocswitch/config.json` by default
|
||||
- proposed log path: `~/.config/ocswitch/request-log.jsonl`
|
||||
|
||||
Reason:
|
||||
|
||||
@ -156,7 +156,7 @@ Each finalized request log entry should contain at least the following fields:
|
||||
"duration_ms": 1881,
|
||||
"protocol": "openai-responses",
|
||||
"alias": "gpt-5.4",
|
||||
"raw_model": "olpx/gpt-5.4",
|
||||
"raw_model": "ocswitch/gpt-5.4",
|
||||
"stream": true,
|
||||
"final_status": 200,
|
||||
"final_provider": "p2",
|
||||
@ -195,7 +195,7 @@ Each finalized request log entry should contain at least the following fields:
|
||||
|
||||
### Required field semantics
|
||||
|
||||
- `alias`: normalized local alias actually routed by `olpx`
|
||||
- `alias`: normalized local alias actually routed by `ocswitch`
|
||||
- `raw_model`: original request payload `model` value before normalization
|
||||
- `final_provider`: provider that produced the downstream response visible to the client
|
||||
- `final_remote_model`: upstream model name actually sent to that provider
|
||||
@ -381,7 +381,7 @@ For MVP, the log viewer can follow the same trust model as the current local pro
|
||||
- same loopback listener by default
|
||||
- intended for single-user localhost usage
|
||||
|
||||
If `olpx` is later allowed to bind non-loopback addresses, the log UI and log API should be explicitly gated before reuse in that mode.
|
||||
If `ocswitch` is later allowed to bind non-loopback addresses, the log UI and log API should be explicitly gated before reuse in that mode.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
@ -438,7 +438,7 @@ Suggested existing files to extend:
|
||||
|
||||
This design is considered implemented successfully when:
|
||||
|
||||
1. running `olpx serve` exposes a local page at `/logs`
|
||||
1. running `ocswitch serve` exposes a local page at `/logs`
|
||||
2. the page shows one row per completed forwarding request
|
||||
3. each row clearly shows final provider and final upstream model
|
||||
4. each row clearly shows whether failover happened
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "forwarding-log-viewer-gui",
|
||||
"name": "forwarding-log-viewer-gui",
|
||||
"title": "Design forwarding log viewer GUI for olpx",
|
||||
"description": "Define a minimal local web UI and request logging design for olpx forwarding operations, including provider/model visibility, token usage, and failover display.",
|
||||
"title": "Design forwarding log viewer GUI for ocswitch",
|
||||
"description": "Define a minimal local web UI and request logging design for ocswitch forwarding operations, including provider/model visibility, token usage, and failover display.",
|
||||
"status": "completed",
|
||||
"dev_type": "docs",
|
||||
"scope": "observability",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "preserve-opencode-model-metadata-sync",
|
||||
"name": "preserve-opencode-model-metadata-sync",
|
||||
"title": "Preserve OpenCode model metadata during olpx sync",
|
||||
"title": "Preserve OpenCode model metadata during ocswitch sync",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
# OLPX Agent-First CLI Help System
|
||||
# OCSWITCH Agent-First CLI Help System
|
||||
|
||||
## Summary
|
||||
|
||||
`olpx` already has a working CLI and a reasonably complete README, but its help
|
||||
`ocswitch` already has a working CLI and a reasonably complete README, but its help
|
||||
surface is still optimized for a human reading source-adjacent docs, not for an
|
||||
AI agent trying to configure the tool end-to-end with high reliability.
|
||||
|
||||
This PRD defines a narrow product change:
|
||||
|
||||
- every user-facing `olpx` command must provide `Long` help
|
||||
- every user-facing `olpx` command must provide `Example` help
|
||||
- every user-facing `ocswitch` command must provide `Long` help
|
||||
- every user-facing `ocswitch` command must provide `Example` help
|
||||
- help content must be written for **AI agent execution reliability first**
|
||||
- manual CLI ergonomics remain important, but are secondary to agent clarity
|
||||
|
||||
The intended result is that an AI agent can use `olpx --help` and nested
|
||||
The intended result is that an AI agent can use `ocswitch --help` and nested
|
||||
`--help` output as a trustworthy local interface contract for discovery,
|
||||
planning, execution, and recovery during configuration.
|
||||
|
||||
## Product Goal
|
||||
|
||||
Make `olpx` self-describing enough that a capable AI agent can complete normal
|
||||
Make `ocswitch` self-describing enough that a capable AI agent can complete normal
|
||||
configuration workflows without relying on hidden tribal knowledge, source code
|
||||
inspection, or README-only instructions.
|
||||
|
||||
@ -27,7 +27,7 @@ inspection, or README-only instructions.
|
||||
|
||||
User wants to say, in effect:
|
||||
|
||||
"Configure `olpx` for my providers and aliases, then sync OpenCode and tell me
|
||||
"Configure `ocswitch` for my providers and aliases, then sync OpenCode and tell me
|
||||
how to run it."
|
||||
|
||||
The agent should be able to do that safely by reading CLI help output and
|
||||
@ -39,9 +39,9 @@ Current repository state already supports the core workflow:
|
||||
|
||||
- add or import upstream providers
|
||||
- create aliases and bind ordered targets
|
||||
- validate config via `olpx doctor`
|
||||
- sync aliases into OpenCode via `olpx opencode sync`
|
||||
- run proxy via `olpx serve`
|
||||
- validate config via `ocswitch doctor`
|
||||
- sync aliases into OpenCode via `ocswitch opencode sync`
|
||||
- run proxy via `ocswitch serve`
|
||||
|
||||
But the command tree does not yet expose the full operational contract through
|
||||
help text. In practice this means:
|
||||
@ -52,7 +52,7 @@ help text. In practice this means:
|
||||
- the "what to do next" step is often only in README, not in command help
|
||||
- an agent may need to infer workflow rules from source code instead of help
|
||||
|
||||
This is acceptable for an engineering MVP, but not good enough if `olpx` wants
|
||||
This is acceptable for an engineering MVP, but not good enough if `ocswitch` wants
|
||||
to recommend agent-driven configuration as a first-class path.
|
||||
|
||||
## Design Principle
|
||||
@ -97,22 +97,22 @@ This means slightly longer help is acceptable if it reduces agent mistakes.
|
||||
|
||||
All user-facing commands in the current Cobra tree:
|
||||
|
||||
- `olpx`
|
||||
- `olpx serve`
|
||||
- `olpx doctor`
|
||||
- `olpx provider`
|
||||
- `olpx provider add`
|
||||
- `olpx provider list`
|
||||
- `olpx provider remove`
|
||||
- `olpx provider import-opencode`
|
||||
- `olpx alias`
|
||||
- `olpx alias add`
|
||||
- `olpx alias list`
|
||||
- `olpx alias bind`
|
||||
- `olpx alias unbind`
|
||||
- `olpx alias remove`
|
||||
- `olpx opencode`
|
||||
- `olpx opencode sync`
|
||||
- `ocswitch`
|
||||
- `ocswitch serve`
|
||||
- `ocswitch doctor`
|
||||
- `ocswitch provider`
|
||||
- `ocswitch provider add`
|
||||
- `ocswitch provider list`
|
||||
- `ocswitch provider remove`
|
||||
- `ocswitch provider import-opencode`
|
||||
- `ocswitch alias`
|
||||
- `ocswitch alias add`
|
||||
- `ocswitch alias list`
|
||||
- `ocswitch alias bind`
|
||||
- `ocswitch alias unbind`
|
||||
- `ocswitch alias remove`
|
||||
- `ocswitch opencode`
|
||||
- `ocswitch opencode sync`
|
||||
|
||||
### Also In Scope
|
||||
|
||||
@ -169,7 +169,7 @@ Examples must avoid:
|
||||
|
||||
### Requirement 3: Root and group commands must explain workflow position
|
||||
|
||||
Commands like `olpx`, `olpx provider`, `olpx alias`, and `olpx opencode` are not
|
||||
Commands like `ocswitch`, `ocswitch provider`, `ocswitch alias`, and `ocswitch opencode` are not
|
||||
action commands themselves, but they are decision points for an agent.
|
||||
|
||||
Their help must answer:
|
||||
@ -184,8 +184,8 @@ Whenever a command writes local config or OpenCode config, help must say so.
|
||||
|
||||
Examples:
|
||||
|
||||
- provider commands write `olpx` config
|
||||
- alias commands write `olpx` config
|
||||
- provider commands write `ocswitch` config
|
||||
- alias commands write `ocswitch` config
|
||||
- `opencode sync` writes the target OpenCode config unless `--dry-run`
|
||||
- `doctor` validates statically and does not call upstream providers
|
||||
- `serve` starts a long-running local proxy and does not mutate config
|
||||
@ -197,7 +197,7 @@ defaults.
|
||||
|
||||
Examples of defaults that must appear where relevant:
|
||||
|
||||
- default `olpx` config path via `--config`
|
||||
- default `ocswitch` config path via `--config`
|
||||
- default OpenCode sync target resolution order
|
||||
- default proxy bind address and API key when describing `serve` or workflows
|
||||
|
||||
@ -209,7 +209,7 @@ config files to chat or expose API keys in logs.
|
||||
|
||||
## Content Contract By Command Type
|
||||
|
||||
### Root Command: `olpx`
|
||||
### Root Command: `ocswitch`
|
||||
|
||||
`Long` should define the full happy-path workflow:
|
||||
|
||||
@ -229,7 +229,7 @@ config files to chat or expose API keys in logs.
|
||||
|
||||
`Long` should explain that providers are upstream OpenAI-compatible endpoints
|
||||
used by alias targets. It should state that provider definitions live in local
|
||||
`olpx` config and are separate from alias routing.
|
||||
`ocswitch` config and are separate from alias routing.
|
||||
|
||||
`Example` should include:
|
||||
|
||||
@ -268,7 +268,7 @@ used by agents to confirm imported or saved provider IDs before binding aliases.
|
||||
|
||||
`Long` should explain:
|
||||
|
||||
- removes provider from `olpx` config
|
||||
- removes provider from `ocswitch` config
|
||||
- does not automatically clean alias references
|
||||
- follow-up `doctor` may fail if aliases still reference removed provider
|
||||
|
||||
@ -296,7 +296,7 @@ used by agents to confirm imported or saved provider IDs before binding aliases.
|
||||
### Group Command: `alias`
|
||||
|
||||
`Long` should explain aliases as the primary user-facing abstraction exposed to
|
||||
OpenCode as `olpx/<alias>`, and that target order defines failover priority.
|
||||
OpenCode as `ocswitch/<alias>`, and that target order defines failover priority.
|
||||
|
||||
`Example` should include:
|
||||
|
||||
@ -366,7 +366,7 @@ OpenCode as `olpx/<alias>`, and that target order defines failover priority.
|
||||
`Long` should explain:
|
||||
|
||||
- removes entire alias from local config
|
||||
- future `opencode sync` will stop exposing it in `provider.olpx.models`
|
||||
- future `opencode sync` will stop exposing it in `provider.ocswitch.models`
|
||||
- does not directly remove model selection already set elsewhere in OpenCode
|
||||
|
||||
`Example` should include:
|
||||
@ -391,7 +391,7 @@ OpenCode as `olpx/<alias>`, and that target order defines failover priority.
|
||||
### Group Command: `opencode`
|
||||
|
||||
`Long` should explain that these commands manage the narrow integration boundary
|
||||
between `olpx` and OpenCode, and do not attempt full OpenCode config takeover.
|
||||
between `ocswitch` and OpenCode, and do not attempt full OpenCode config takeover.
|
||||
|
||||
`Example` should include:
|
||||
|
||||
@ -407,7 +407,7 @@ contract.
|
||||
|
||||
- exact write target rules
|
||||
- what fields are mutated and not mutated
|
||||
- that aliases become `provider.olpx.models`
|
||||
- that aliases become `provider.ocswitch.models`
|
||||
- meaning of `--dry-run`
|
||||
- recommended call order around `doctor`
|
||||
|
||||
@ -481,7 +481,7 @@ scenarios without README-only dependency.
|
||||
|
||||
### Scenario 1: Configure from scratch
|
||||
|
||||
1. inspect `olpx --help`
|
||||
1. inspect `ocswitch --help`
|
||||
2. add one or more providers
|
||||
3. add alias
|
||||
4. bind targets in order
|
||||
@ -491,7 +491,7 @@ scenarios without README-only dependency.
|
||||
|
||||
### Scenario 2: Import existing OpenCode provider definitions first
|
||||
|
||||
1. inspect `olpx provider import-opencode --help`
|
||||
1. inspect `ocswitch provider import-opencode --help`
|
||||
2. import supported providers
|
||||
3. list providers
|
||||
4. create aliases and bindings
|
||||
@ -527,9 +527,9 @@ README should not be the only place where critical command semantics live.
|
||||
|
||||
### Functional Acceptance
|
||||
|
||||
1. Every user-facing Cobra command in the current `olpx` tree defines non-empty
|
||||
1. Every user-facing Cobra command in the current `ocswitch` tree defines non-empty
|
||||
`Long` and non-empty `Example` help.
|
||||
2. `olpx --help` and all nested `--help` pages expose enough information for an
|
||||
2. `ocswitch --help` and all nested `--help` pages expose enough information for an
|
||||
agent to discover the intended configuration workflow without reading source.
|
||||
3. Help text for mutating commands explicitly states what files/config are
|
||||
written.
|
||||
@ -593,14 +593,14 @@ duplication becomes genuinely harmful.
|
||||
|
||||
These are explicitly out of this task, but compatible with it later:
|
||||
|
||||
- `olpx setup` guided workflow command
|
||||
- `ocswitch setup` guided workflow command
|
||||
- shell completion tuned for provider/alias names
|
||||
- structured `--help-format json` for agent-native consumption
|
||||
- README agent prompt template that mirrors the help contract
|
||||
|
||||
## Final Product Decision
|
||||
|
||||
`olpx` should recommend an **agent-first, CLI-native** configuration workflow.
|
||||
`ocswitch` should recommend an **agent-first, CLI-native** configuration workflow.
|
||||
|
||||
The CLI itself must become the most reliable place to learn how to configure the
|
||||
tool. `Long` and `Example` are therefore not documentation polish; they are part
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"id": "04-17-agent-first-cli-help-prd",
|
||||
"name": "04-17-agent-first-cli-help-prd",
|
||||
"title": "PRD: agent-first CLI help system",
|
||||
"description": "Define an agent-first product spec for olpx CLI Long/Example help and related docs behavior",
|
||||
"description": "Define an agent-first product spec for ocswitch CLI Long/Example help and related docs behavior",
|
||||
"status": "completed",
|
||||
"dev_type": "docs",
|
||||
"scope": null,
|
||||
@ -57,7 +57,7 @@
|
||||
"internal/cli/serve.go",
|
||||
"README.md"
|
||||
],
|
||||
"notes": "This task defines an agent-first PRD for making olpx CLI help text a reliable configuration interface for AI agents.",
|
||||
"notes": "This task defines an agent-first PRD for making ocswitch CLI help text a reliable configuration interface for AI agents.",
|
||||
"meta": {
|
||||
"primaryAudience": "ai-agent",
|
||||
"priorityRule": "agent-first-manual-second",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# OLPX MVP Redesign
|
||||
# OCSWITCH MVP Redesign
|
||||
|
||||
## Summary
|
||||
|
||||
`opencode-provider-switch` (`olpx`) is a local proxy for OpenCode focused on one narrow job:
|
||||
`opencode-provider-switch` (`ocswitch`) is a local proxy for OpenCode focused on one narrow job:
|
||||
|
||||
- expose one stable local provider to OpenCode
|
||||
- let users select logical model aliases instead of concrete upstream models
|
||||
@ -15,7 +15,7 @@ MVP is about one thing: **multi-provider failover behind a stable OpenCode model
|
||||
|
||||
## Product Goal
|
||||
|
||||
When a user chooses `olpx/<alias>` inside OpenCode, `olpx` should transparently try the configured upstream targets in priority order until one succeeds, without the user needing to care which provider actually served the request.
|
||||
When a user chooses `ocswitch/<alias>` inside OpenCode, `ocswitch` should transparently try the configured upstream targets in priority order until one succeeds, without the user needing to care which provider actually served the request.
|
||||
|
||||
## Core User Need
|
||||
|
||||
@ -56,13 +56,13 @@ User wants:
|
||||
|
||||
## Architecture in One Sentence
|
||||
|
||||
OpenCode sends `POST /v1/responses` to local provider `olpx`; `olpx` resolves requested alias to an ordered target list and proxies request to first healthy upstream candidate.
|
||||
OpenCode sends `POST /v1/responses` to local provider `ocswitch`; `ocswitch` resolves requested alias to an ordered target list and proxies request to first healthy upstream candidate.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```text
|
||||
OpenCode
|
||||
-> custom provider `olpx` (@ai-sdk/openai)
|
||||
-> custom provider `ocswitch` (@ai-sdk/openai)
|
||||
-> http://127.0.0.1:9982/v1/responses
|
||||
-> alias resolver
|
||||
-> failover engine
|
||||
@ -77,10 +77,10 @@ OpenCode
|
||||
|
||||
MVP should expose exactly one local provider to OpenCode:
|
||||
|
||||
- provider id: `olpx`
|
||||
- provider id: `ocswitch`
|
||||
- npm package: `@ai-sdk/openai`
|
||||
- base URL: `http://127.0.0.1:9982/v1`
|
||||
- local API key: static placeholder such as `olpx-local`
|
||||
- local API key: static placeholder such as `ocswitch-local`
|
||||
|
||||
Conceptual OpenCode config shape:
|
||||
|
||||
@ -88,12 +88,12 @@ Conceptual OpenCode config shape:
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"olpx": {
|
||||
"ocswitch": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OPS",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local"
|
||||
"apiKey": "ocswitch-local"
|
||||
},
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
@ -105,7 +105,7 @@ Conceptual OpenCode config shape:
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "olpx/gpt-5.4"
|
||||
"model": "ocswitch/gpt-5.4"
|
||||
}
|
||||
```
|
||||
|
||||
@ -117,23 +117,23 @@ OpenCode source confirms this path is valid.
|
||||
|
||||
OpenCode Web/TUI model pickers also read runtime provider state and only surface models from connected providers.
|
||||
|
||||
OpenCode provider loading also merges custom config-defined providers and models into runtime provider state. That means `olpx` does **not** need a special external model catalog protocol for MVP.
|
||||
OpenCode provider loading also merges custom config-defined providers and models into runtime provider state. That means `ocswitch` does **not** need a special external model catalog protocol for MVP.
|
||||
|
||||
Simplest MVP path:
|
||||
|
||||
- keep alias list in `olpx`
|
||||
- sync alias list into OpenCode `provider.olpx.models`
|
||||
- make sure `provider.olpx` is valid enough to appear as a connected runtime provider
|
||||
- let OpenCode surface `olpx/<alias>` in `/models` and `/model`
|
||||
- keep alias list in `ocswitch`
|
||||
- sync alias list into OpenCode `provider.ocswitch.models`
|
||||
- make sure `provider.ocswitch` is valid enough to appear as a connected runtime provider
|
||||
- let OpenCode surface `ocswitch/<alias>` in `/models` and `/model`
|
||||
|
||||
### Important Scope Rule
|
||||
|
||||
MVP should **not** rewrite the entire OpenCode config.
|
||||
|
||||
Instead, `olpx` should only support a narrow integration step:
|
||||
Instead, `ocswitch` should only support a narrow integration step:
|
||||
|
||||
- ensure `provider.olpx` exists or is updated
|
||||
- optionally sync alias entries into `provider.olpx.models`
|
||||
- ensure `provider.ocswitch` exists or is updated
|
||||
- optionally sync alias entries into `provider.ocswitch.models`
|
||||
- optionally let user set `model` or `small_model` manually
|
||||
|
||||
This avoids the previous PRD's high-risk install/restore workflow.
|
||||
@ -144,7 +144,7 @@ OpenCode source also confirms config is merged from multiple layers, and lower-p
|
||||
|
||||
Implication for MVP:
|
||||
|
||||
- `olpx opencode sync` must know which config file it is targeting
|
||||
- `ocswitch opencode sync` must know which config file it is targeting
|
||||
- MVP should not silently write one low-precedence file and assume aliases will appear at runtime
|
||||
- default target should be global user config under `~/.config/opencode/`
|
||||
- if global config already exists, reuse existing main file in this order: `opencode.jsonc`, `opencode.json`, `config.json`
|
||||
@ -153,11 +153,11 @@ Implication for MVP:
|
||||
|
||||
## Provider Source Model
|
||||
|
||||
`olpx` needs upstream provider definitions, but this is no longer the product center.
|
||||
`ocswitch` needs upstream provider definitions, but this is no longer the product center.
|
||||
|
||||
MVP should support two input paths:
|
||||
|
||||
1. manual provider entry through `olpx` CLI
|
||||
1. manual provider entry through `ocswitch` CLI
|
||||
2. one-shot import from one explicit OpenCode config file, defaulting to global user config
|
||||
|
||||
### Supported Provider Shape In MVP
|
||||
@ -179,7 +179,7 @@ OpenCode source also shows that `auth.json` provides credentials for an existing
|
||||
|
||||
Implication for MVP:
|
||||
|
||||
- `olpx` should import provider definitions from config, not from merged runtime auth state
|
||||
- `ocswitch` should import provider definitions from config, not from merged runtime auth state
|
||||
- `auth.json` support, if ever added later, should be treated as credential enrichment for already-known provider IDs
|
||||
- MVP import does not need to evaluate account config, managed config, or remote well-known config layers
|
||||
|
||||
@ -200,8 +200,8 @@ User should use alias directly inside OpenCode. User should not need to know con
|
||||
1. Every alias maps to one or more concrete targets.
|
||||
2. Every alias must contain at least one enabled target.
|
||||
3. Alias target order is explicit and defines failover priority.
|
||||
4. Alias name must be unique within `olpx`.
|
||||
5. OpenCode should reference alias as `olpx/<alias>`.
|
||||
4. Alias name must be unique within `ocswitch`.
|
||||
5. OpenCode should reference alias as `ocswitch/<alias>`.
|
||||
|
||||
### Example
|
||||
|
||||
@ -234,7 +234,7 @@ Rich capability metadata such as `limit`, `attachment`, `reasoning`, `tool_call`
|
||||
1. Receive request from OpenCode.
|
||||
2. Read and buffer full JSON request body once.
|
||||
3. Parse `model` from that JSON body.
|
||||
4. Treat `model` as `olpx` alias.
|
||||
4. Treat `model` as `ocswitch` alias.
|
||||
5. Resolve alias to ordered enabled targets.
|
||||
6. Replace only alias model field with concrete upstream model ID.
|
||||
7. Forward request to highest-priority target.
|
||||
@ -303,13 +303,13 @@ Conservative failover is preferable to surprising failover.
|
||||
|
||||
## Proxy Debugging Headers
|
||||
|
||||
For debugging, `olpx` should add response headers where possible:
|
||||
For debugging, `ocswitch` should add response headers where possible:
|
||||
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
These headers are cheap and make failover behavior understandable.
|
||||
|
||||
@ -321,7 +321,7 @@ MVP should prefer a simpler user-editable config shape unless implementation pro
|
||||
|
||||
### Recommended MVP Direction
|
||||
|
||||
Use one local `olpx` JSON or JSONC config file for:
|
||||
Use one local `ocswitch` JSON or JSONC config file for:
|
||||
|
||||
- upstream providers
|
||||
- aliases
|
||||
@ -341,19 +341,19 @@ Recommended MVP commands:
|
||||
|
||||
### Core
|
||||
|
||||
- `olpx serve`
|
||||
- `olpx doctor`
|
||||
- `ocswitch serve`
|
||||
- `ocswitch doctor`
|
||||
|
||||
### `olpx doctor` MVP Boundary
|
||||
### `ocswitch doctor` MVP Boundary
|
||||
|
||||
First-release `olpx doctor` should stay side-effect free.
|
||||
First-release `ocswitch doctor` should stay side-effect free.
|
||||
|
||||
It should validate:
|
||||
|
||||
- local `olpx` config can be loaded
|
||||
- local `ocswitch` config can be loaded
|
||||
- every alias resolves to at least one enabled target
|
||||
- local proxy bind address and config are internally consistent
|
||||
- generated or synced `provider.olpx` config shape is structurally valid
|
||||
- generated or synced `provider.ocswitch` config shape is structurally valid
|
||||
|
||||
It should not, by default:
|
||||
|
||||
@ -361,35 +361,35 @@ It should not, by default:
|
||||
- consume quota from user providers
|
||||
- mutate local or OpenCode config as part of diagnosis
|
||||
|
||||
If live upstream probing is needed later, it should be added as explicit opt-in behavior such as `olpx doctor --live`.
|
||||
If live upstream probing is needed later, it should be added as explicit opt-in behavior such as `ocswitch doctor --live`.
|
||||
|
||||
### Provider Management
|
||||
|
||||
- `olpx provider add`
|
||||
- `olpx provider list`
|
||||
- `olpx provider remove`
|
||||
- `olpx provider import-opencode`
|
||||
- `ocswitch provider add`
|
||||
- `ocswitch provider list`
|
||||
- `ocswitch provider remove`
|
||||
- `ocswitch provider import-opencode`
|
||||
|
||||
### Alias Management
|
||||
|
||||
- `olpx alias add`
|
||||
- `olpx alias list`
|
||||
- `olpx alias bind`
|
||||
- `olpx alias unbind`
|
||||
- `olpx alias remove`
|
||||
- `ocswitch alias add`
|
||||
- `ocswitch alias list`
|
||||
- `ocswitch alias bind`
|
||||
- `ocswitch alias unbind`
|
||||
- `ocswitch alias remove`
|
||||
|
||||
### OpenCode Integration
|
||||
|
||||
- `olpx opencode sync`
|
||||
- `ocswitch opencode sync`
|
||||
|
||||
## `olpx opencode sync` Responsibility
|
||||
## `ocswitch opencode sync` Responsibility
|
||||
|
||||
This command should do one narrow job:
|
||||
|
||||
- by default update or create custom provider `olpx` in global OpenCode user config
|
||||
- by default update or create custom provider `ocswitch` in global OpenCode user config
|
||||
- prefer existing global config file in this order: `opencode.jsonc`, `opencode.json`, `config.json`
|
||||
- if none exists, create `~/.config/opencode/opencode.jsonc`
|
||||
- sync current alias names into `provider.olpx.models`
|
||||
- sync current alias names into `provider.ocswitch.models`
|
||||
|
||||
Optional extra behavior:
|
||||
|
||||
@ -418,15 +418,15 @@ Conclusion:
|
||||
|
||||
- alias exposure inside OpenCode is feasible in MVP
|
||||
- simplest path is config sync, not custom remote model registry work
|
||||
- syncing alias keys into `provider.olpx.models` is sufficient for exposure if `provider.olpx` lands in connected runtime provider state
|
||||
- syncing alias keys into `provider.ocswitch.models` is sufficient for exposure if `provider.ocswitch` lands in connected runtime provider state
|
||||
|
||||
## Security Model
|
||||
|
||||
MVP security posture should stay simple:
|
||||
|
||||
- listen on `127.0.0.1` by default
|
||||
- use static local placeholder API key between OpenCode and `olpx`
|
||||
- store upstream credentials in local `olpx` config
|
||||
- use static local placeholder API key between OpenCode and `ocswitch`
|
||||
- store upstream credentials in local `ocswitch` config
|
||||
- document that local credential storage is sensitive
|
||||
|
||||
No multi-user or remote-network security guarantees in MVP.
|
||||
@ -437,9 +437,9 @@ MVP is successful if a user can:
|
||||
|
||||
1. configure at least two upstream providers manually or through OpenCode sync
|
||||
2. create an alias with ordered targets across those providers
|
||||
3. run `olpx opencode sync` and see alias names appear in OpenCode `opencode models` output and `/model` picker
|
||||
4. select `olpx/<alias>` in OpenCode without exposing concrete upstream model IDs
|
||||
5. send normal streaming OpenAI Responses traffic through `olpx`
|
||||
3. run `ocswitch opencode sync` and see alias names appear in OpenCode `opencode models` output and `/model` picker
|
||||
4. select `ocswitch/<alias>` in OpenCode without exposing concrete upstream model IDs
|
||||
5. send normal streaming OpenAI Responses traffic through `ocswitch`
|
||||
6. get automatic failover when primary provider returns `429` or `5xx`, or fails before first downstream byte
|
||||
7. observe that once a stream has started, later upstream failure is surfaced as failure rather than hidden mid-stream switching
|
||||
|
||||
@ -454,10 +454,10 @@ MVP is successful if a user can:
|
||||
|
||||
### Phase 2
|
||||
|
||||
- `olpx opencode sync`
|
||||
- `ocswitch opencode sync`
|
||||
- narrow OpenCode provider import
|
||||
- alias list exposure in OpenCode config
|
||||
- connected-provider validation in `olpx doctor`
|
||||
- connected-provider validation in `ocswitch doctor`
|
||||
|
||||
### Phase 3
|
||||
|
||||
@ -469,16 +469,16 @@ MVP is successful if a user can:
|
||||
|
||||
### Phase 4
|
||||
|
||||
- `olpx doctor`
|
||||
- `ocswitch doctor`
|
||||
- debugging headers and logs
|
||||
|
||||
## Finalized MVP Decisions
|
||||
|
||||
The following implementation choices are now locked for first-release MVP:
|
||||
|
||||
1. `olpx opencode sync` updates `provider.olpx` and alias exposure only by default. It must not modify OpenCode `model` or `small_model` unless explicit opt-in flags are provided.
|
||||
1. `ocswitch opencode sync` updates `provider.ocswitch` and alias exposure only by default. It must not modify OpenCode `model` or `small_model` unless explicit opt-in flags are provided.
|
||||
2. Provider import support is limited to config-defined `@ai-sdk/openai` custom providers.
|
||||
3. `olpx doctor` is static by default and must not issue live upstream requests unless future explicit opt-in behavior is added.
|
||||
3. `ocswitch doctor` is static by default and must not issue live upstream requests unless future explicit opt-in behavior is added.
|
||||
|
||||
## Strong Recommendation
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# OLPX MVP Design
|
||||
# OCSWITCH MVP Design
|
||||
|
||||
## Summary
|
||||
|
||||
`opencode-provider-switch` (`olpx`) is a local CLI + proxy for OpenCode.
|
||||
`opencode-provider-switch` (`ocswitch`) is a local CLI + proxy for OpenCode.
|
||||
|
||||
Its job is narrow:
|
||||
|
||||
@ -10,7 +10,7 @@ Its job is narrow:
|
||||
- Route requests by protocol, not by provider brand
|
||||
- Retry/fail over across unreliable upstream relay providers
|
||||
- Let multiple upstream providers share one logical model alias
|
||||
- Manage state in SQLite, not in an `olpx` config file
|
||||
- Manage state in SQLite, not in an `ocswitch` config file
|
||||
- Rewrite OpenCode global config to point at the local proxy
|
||||
|
||||
MVP intentionally does **not** try to become a general AI gateway, a dashboard product, or a provider-agnostic orchestration platform.
|
||||
@ -32,7 +32,7 @@ MVP intentionally does **not** try to become a general AI gateway, a dashboard p
|
||||
## Product Goals
|
||||
|
||||
1. Keep OpenCode usable when cheap relay providers fail intermittently.
|
||||
2. Reduce OpenCode config complexity by centralizing provider management in `olpx`.
|
||||
2. Reduce OpenCode config complexity by centralizing provider management in `ocswitch`.
|
||||
3. Make failover behavior predictable, visible, and debuggable.
|
||||
4. Preserve OpenCode-native workflow instead of asking users to switch tools.
|
||||
|
||||
@ -57,9 +57,9 @@ Go is the right fit for this product shape:
|
||||
|
||||
## Core Design Principle
|
||||
|
||||
`olpx` should manage **protocol pools** and **logical aliases**, not raw provider selection inside OpenCode.
|
||||
`ocswitch` should manage **protocol pools** and **logical aliases**, not raw provider selection inside OpenCode.
|
||||
|
||||
OpenCode should see a small number of local proxy providers. `olpx` should own:
|
||||
OpenCode should see a small number of local proxy providers. `ocswitch` should own:
|
||||
|
||||
- upstream providers
|
||||
- API keys and headers
|
||||
@ -74,7 +74,7 @@ This keeps OpenCode config stable even when upstream providers are added, remove
|
||||
```text
|
||||
OpenCode
|
||||
-> local OpenAI-compatible provider config
|
||||
-> olpx proxy (127.0.0.1:9982)
|
||||
-> ocswitch proxy (127.0.0.1:9982)
|
||||
-> protocol router
|
||||
-> alias resolver
|
||||
-> failover engine
|
||||
@ -127,18 +127,18 @@ OpenCode
|
||||
|
||||
Even though both protocols are OpenAI-family APIs, they are **not** interchangeable at the OpenCode config level.
|
||||
|
||||
For MVP, `olpx` should expose two local providers to OpenCode:
|
||||
For MVP, `ocswitch` should expose two local providers to OpenCode:
|
||||
|
||||
- one chat-completions provider
|
||||
- one responses provider
|
||||
|
||||
This mirrors how OpenCode expects provider wiring today and avoids hidden protocol translation logic inside `olpx`.
|
||||
This mirrors how OpenCode expects provider wiring today and avoids hidden protocol translation logic inside `ocswitch`.
|
||||
|
||||
## OpenCode Integration Strategy
|
||||
|
||||
### Generated OpenCode Config
|
||||
|
||||
`olpx install` should rewrite the user's global OpenCode config so that OpenCode points to local proxy providers instead of raw upstream relays.
|
||||
`ocswitch install` should rewrite the user's global OpenCode config so that OpenCode points to local proxy providers instead of raw upstream relays.
|
||||
|
||||
Generated provider shape should be conceptually like this:
|
||||
|
||||
@ -146,12 +146,12 @@ Generated provider shape should be conceptually like this:
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"olpx-chat": {
|
||||
"ocswitch-chat": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "OLPX Chat",
|
||||
"name": "OCSWITCH Chat",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local"
|
||||
"apiKey": "ocswitch-local"
|
||||
},
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
@ -159,12 +159,12 @@ Generated provider shape should be conceptually like this:
|
||||
}
|
||||
}
|
||||
},
|
||||
"olpx-responses": {
|
||||
"ocswitch-responses": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OLPX Responses",
|
||||
"name": "OCSWITCH Responses",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local"
|
||||
"apiKey": "ocswitch-local"
|
||||
},
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
@ -173,16 +173,16 @@ Generated provider shape should be conceptually like this:
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "olpx-responses/gpt-5.4",
|
||||
"small_model": "olpx-chat/gpt-5.4-mini"
|
||||
"model": "ocswitch-responses/gpt-5.4",
|
||||
"small_model": "ocswitch-chat/gpt-5.4-mini"
|
||||
}
|
||||
```
|
||||
|
||||
### Important Rule
|
||||
|
||||
`olpx` should preserve as much of the user's existing OpenCode config as possible.
|
||||
`ocswitch` should preserve as much of the user's existing OpenCode config as possible.
|
||||
|
||||
`olpx install` should:
|
||||
`ocswitch install` should:
|
||||
|
||||
1. Back up current global config file.
|
||||
2. Import provider/model information relevant to migration.
|
||||
@ -196,12 +196,12 @@ Generated provider shape should be conceptually like this:
|
||||
|
||||
OpenCode merges config from multiple sources, and project config can override global config.
|
||||
|
||||
That means `olpx install` cannot guarantee full interception if a repository-local `opencode.json` or environment override replaces provider/model settings.
|
||||
That means `ocswitch install` cannot guarantee full interception if a repository-local `opencode.json` or environment override replaces provider/model settings.
|
||||
|
||||
MVP response:
|
||||
|
||||
- document this clearly
|
||||
- make `olpx doctor` detect likely overrides
|
||||
- make `ocswitch doctor` detect likely overrides
|
||||
- support global install first
|
||||
|
||||
Do **not** promise perfect takeover across all OpenCode precedence layers in MVP.
|
||||
@ -210,7 +210,7 @@ Do **not** promise perfect takeover across all OpenCode precedence layers in MVP
|
||||
|
||||
### Input Sources
|
||||
|
||||
`olpx install` should inspect:
|
||||
`ocswitch install` should inspect:
|
||||
|
||||
1. `~/.config/opencode/opencode.json`
|
||||
2. `~/.config/opencode/opencode.jsonc`
|
||||
@ -236,7 +236,7 @@ For each imported provider/model entry from OpenCode:
|
||||
- tool_call
|
||||
- options
|
||||
- variants
|
||||
5. Generate local `olpx-*` providers for OpenCode.
|
||||
5. Generate local `ocswitch-*` providers for OpenCode.
|
||||
|
||||
### Protocol Classification
|
||||
|
||||
@ -246,18 +246,18 @@ Import classification rules for MVP:
|
||||
- `@ai-sdk/openai` -> `openai-responses`
|
||||
- everything else -> unsupported for automatic migration in MVP
|
||||
|
||||
If unsupported providers exist, `olpx install` should warn and skip them instead of guessing.
|
||||
If unsupported providers exist, `ocswitch install` should warn and skip them instead of guessing.
|
||||
|
||||
## SQLite-First State Model
|
||||
|
||||
`olpx` should not keep its own user-editable config file.
|
||||
`ocswitch` should not keep its own user-editable config file.
|
||||
|
||||
Recommended database path:
|
||||
|
||||
- Linux/WSL: `~/.local/share/olpx/olpx.db`
|
||||
- Linux/WSL: `~/.local/share/ocswitch/ocswitch.db`
|
||||
- Windows native: use `os.UserConfigDir()` or `os.UserCacheDir()`-appropriate app path, finalized in implementation
|
||||
|
||||
The generated OpenCode config file is an output artifact, not `olpx` source of truth.
|
||||
The generated OpenCode config file is an output artifact, not `ocswitch` source of truth.
|
||||
|
||||
### Proposed Tables
|
||||
|
||||
@ -368,7 +368,7 @@ Example:
|
||||
- responses target priority 2: provider `codex-for-me`, remote model `GPT-5.4`
|
||||
- chat target priority 1: provider `relay-x`, remote model `gpt-5.4-chat`
|
||||
|
||||
This allows the user to keep using one stable model name inside OpenCode while `olpx` handles provider-specific naming.
|
||||
This allows the user to keep using one stable model name inside OpenCode while `ocswitch` handles provider-specific naming.
|
||||
|
||||
### Important Constraint
|
||||
|
||||
@ -386,13 +386,13 @@ Reduce manual provider setup work.
|
||||
|
||||
### Behavior
|
||||
|
||||
`olpx` should be able to call upstream `GET /v1/models` and cache results per provider.
|
||||
`ocswitch` should be able to call upstream `GET /v1/models` and cache results per provider.
|
||||
|
||||
Useful commands:
|
||||
|
||||
- `olpx provider models sync <provider>`
|
||||
- `olpx provider models list <provider>`
|
||||
- `olpx alias suggest <provider>`
|
||||
- `ocswitch provider models sync <provider>`
|
||||
- `ocswitch provider models list <provider>`
|
||||
- `ocswitch alias suggest <provider>`
|
||||
|
||||
### Important Limitation
|
||||
|
||||
@ -432,7 +432,7 @@ Same as above, with one critical rule:
|
||||
|
||||
- failover is only allowed **before first upstream response byte is sent to the client**
|
||||
|
||||
Once a stream begins successfully, `olpx` must stay on that provider for that request.
|
||||
Once a stream begins successfully, `ocswitch` must stay on that provider for that request.
|
||||
|
||||
## Failover Policy
|
||||
|
||||
@ -477,12 +477,12 @@ This is conservative, but predictable.
|
||||
|
||||
For debugging, add response headers when possible:
|
||||
|
||||
- `X-OLPX-Protocol`
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Protocol`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
These headers are low-cost and help explain behavior fast.
|
||||
|
||||
@ -493,7 +493,7 @@ MVP security posture should be intentionally modest but clear.
|
||||
### Default Behavior
|
||||
|
||||
- bind only to `127.0.0.1`
|
||||
- generated OpenCode config uses local static API key like `olpx-local`
|
||||
- generated OpenCode config uses local static API key like `ocswitch-local`
|
||||
- proxy accepts only loopback traffic by default
|
||||
|
||||
### Why This Is Acceptable For MVP
|
||||
@ -528,40 +528,40 @@ Proposed command set:
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- `olpx init`
|
||||
- `olpx serve`
|
||||
- `olpx doctor`
|
||||
- `olpx install`
|
||||
- `olpx restore`
|
||||
- `ocswitch init`
|
||||
- `ocswitch serve`
|
||||
- `ocswitch doctor`
|
||||
- `ocswitch install`
|
||||
- `ocswitch restore`
|
||||
|
||||
### Provider Management
|
||||
|
||||
- `olpx provider add`
|
||||
- `olpx provider list`
|
||||
- `olpx provider edit`
|
||||
- `olpx provider remove`
|
||||
- `olpx provider enable`
|
||||
- `olpx provider disable`
|
||||
- `ocswitch provider add`
|
||||
- `ocswitch provider list`
|
||||
- `ocswitch provider edit`
|
||||
- `ocswitch provider remove`
|
||||
- `ocswitch provider enable`
|
||||
- `ocswitch provider disable`
|
||||
|
||||
### Model Discovery
|
||||
|
||||
- `olpx provider models sync <provider>`
|
||||
- `olpx provider models list <provider>`
|
||||
- `ocswitch provider models sync <provider>`
|
||||
- `ocswitch provider models list <provider>`
|
||||
|
||||
### Alias Management
|
||||
|
||||
- `olpx alias add`
|
||||
- `olpx alias list`
|
||||
- `olpx alias bind`
|
||||
- `olpx alias unbind`
|
||||
- `olpx alias enable`
|
||||
- `olpx alias disable`
|
||||
- `olpx alias inspect <alias>`
|
||||
- `ocswitch alias add`
|
||||
- `ocswitch alias list`
|
||||
- `ocswitch alias bind`
|
||||
- `ocswitch alias unbind`
|
||||
- `ocswitch alias enable`
|
||||
- `ocswitch alias disable`
|
||||
- `ocswitch alias inspect <alias>`
|
||||
|
||||
### Diagnostics
|
||||
|
||||
- `olpx logs tail`
|
||||
- `olpx route test --protocol <protocol> --model <alias>`
|
||||
- `ocswitch logs tail`
|
||||
- `ocswitch route test --protocol <protocol> --model <alias>`
|
||||
|
||||
## Recommended Minimal UX
|
||||
|
||||
@ -569,20 +569,20 @@ Prefer explicit CLI over magical automation.
|
||||
|
||||
Good path:
|
||||
|
||||
1. `olpx init`
|
||||
2. `olpx provider add`
|
||||
3. `olpx provider models sync`
|
||||
4. `olpx alias add`
|
||||
5. `olpx alias bind`
|
||||
6. `olpx install`
|
||||
7. `olpx serve`
|
||||
1. `ocswitch init`
|
||||
2. `ocswitch provider add`
|
||||
3. `ocswitch provider models sync`
|
||||
4. `ocswitch alias add`
|
||||
5. `ocswitch alias bind`
|
||||
6. `ocswitch install`
|
||||
7. `ocswitch serve`
|
||||
|
||||
This is easy to explain and easy to debug.
|
||||
|
||||
## Suggested Go Package Layout
|
||||
|
||||
```text
|
||||
cmd/olpx/
|
||||
cmd/ocswitch/
|
||||
internal/cli/
|
||||
internal/db/
|
||||
internal/models/
|
||||
@ -608,12 +608,12 @@ Avoid adding a heavy HTTP framework unless a real need appears.
|
||||
|
||||
## Generated OpenCode Provider Strategy
|
||||
|
||||
MVP should generate only providers that `olpx` can actually back.
|
||||
MVP should generate only providers that `ocswitch` can actually back.
|
||||
|
||||
That means:
|
||||
|
||||
- `olpx-chat`
|
||||
- `olpx-responses`
|
||||
- `ocswitch-chat`
|
||||
- `ocswitch-responses`
|
||||
|
||||
Do **not** generate fake Anthropic provider entries in MVP.
|
||||
|
||||
@ -621,7 +621,7 @@ Do **not** attempt to preserve original provider IDs inside OpenCode after insta
|
||||
|
||||
Reason:
|
||||
|
||||
- `olpx` becomes the stable local provider boundary
|
||||
- `ocswitch` becomes the stable local provider boundary
|
||||
- upstream providers should move into SQLite management only
|
||||
|
||||
## WSL / Windows Strategy
|
||||
@ -632,18 +632,18 @@ This requirement is important, but it needs careful wording.
|
||||
|
||||
1. Native Linux/WSL build works.
|
||||
2. Native Windows build works.
|
||||
3. Running OpenCode and `olpx` in the **same environment** is supported.
|
||||
4. `olpx doctor` helps detect config-path and loopback issues.
|
||||
3. Running OpenCode and `ocswitch` in the **same environment** is supported.
|
||||
4. `ocswitch doctor` helps detect config-path and loopback issues.
|
||||
|
||||
### What MVP Should Not Promise Yet
|
||||
|
||||
1. Fully automatic cross-boundary migration between WSL OpenCode and Windows `olpx`.
|
||||
1. Fully automatic cross-boundary migration between WSL OpenCode and Windows `ocswitch`.
|
||||
2. Transparent path translation for every user setup.
|
||||
3. Zero-config interop when OpenCode runs on one side and proxy on the other.
|
||||
|
||||
### Practical Recommendation
|
||||
|
||||
For MVP, recommend users run OpenCode and `olpx` in the same environment.
|
||||
For MVP, recommend users run OpenCode and `ocswitch` in the same environment.
|
||||
|
||||
Cross-environment support can be added later via explicit install target flags.
|
||||
|
||||
@ -655,7 +655,7 @@ Global config rewrite alone may not capture project-level overrides.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- `olpx doctor`
|
||||
- `ocswitch doctor`
|
||||
- clear docs
|
||||
- possible future `ops install --project`
|
||||
|
||||
@ -704,7 +704,7 @@ MVP is successful if a user can:
|
||||
1. Import existing OpenCode provider setup into `ops`.
|
||||
2. Create or verify aliases for commonly used models.
|
||||
3. Install proxy-backed OpenCode global config.
|
||||
4. Run `olpx serve`.
|
||||
4. Run `ocswitch serve`.
|
||||
5. Use OpenCode normally against `127.0.0.1:9982`.
|
||||
6. Survive common upstream failures by automatic provider failover.
|
||||
|
||||
@ -734,7 +734,7 @@ MVP is successful if a user can:
|
||||
|
||||
- streaming support
|
||||
- logging/diagnostics
|
||||
- `olpx doctor`
|
||||
- `ocswitch doctor`
|
||||
- restore flow
|
||||
|
||||
## Strong Recommendation
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "provider-disable-failover",
|
||||
"name": "provider-disable-failover",
|
||||
"title": "Support disabling providers in olpx",
|
||||
"title": "Support disabling providers in ocswitch",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|---|------|-------|---------|--------|
|
||||
| 5 | 2026-04-17 | Fix sync command panic | `887eb14` | `master` |
|
||||
| 4 | 2026-04-17 | Review MVP completion status | - | `master` |
|
||||
| 3 | 2026-04-17 | Support disabling providers in olpx | HEAD | `master` |
|
||||
| 3 | 2026-04-17 | Support disabling providers in ocswitch | HEAD | `master` |
|
||||
| 2 | 2026-04-17 | Build OPS MVP failover proxy | `2b04d91` | `master` |
|
||||
| 1 | 2026-04-17 | Finalize OPS MVP review PRD | `eeacdc4`, `00747fa`, `ca0b3c4` | `master` |
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
@ -91,10 +91,10 @@ Implemented the Go-based OPS MVP: local config, provider and alias CLI, OpenCode
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 3: Support disabling providers in olpx
|
||||
## Session 3: Support disabling providers in ocswitch
|
||||
|
||||
**Date**: 2026-04-17
|
||||
**Task**: Support disabling providers in olpx
|
||||
**Task**: Support disabling providers in ocswitch
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
@ -139,7 +139,7 @@ Reviewed current implementation against the archived MVP PRD and classified impl
|
||||
| Category | Result |
|
||||
|---|---|
|
||||
| Overall status | MVP core flow is effectively complete |
|
||||
| Implemented MVP | Local olpx config, provider/alias CLI, OpenCode sync, static doctor, `/v1/responses` proxy, streaming pass-through, pre-first-byte failover, debug headers |
|
||||
| Implemented MVP | Local ocswitch config, provider/alias CLI, OpenCode sync, static doctor, `/v1/responses` proxy, streaming pass-through, pre-first-byte failover, debug headers |
|
||||
| Partial / missing MVP edges | `doctor` validates generated preview more than on-disk synced state; OpenCode provider import only reads `options.apiKey`, not broader header-style auth |
|
||||
| Beyond MVP | `provider enable/disable`, minimal `/v1/models`, broader alias normalization, careful OpenCode config patching |
|
||||
|
||||
@ -191,7 +191,7 @@ Fixed a panic in opencode sync when preserved model metadata contains slices, an
|
||||
|
||||
### Main Changes
|
||||
|
||||
- Root cause: `internal/opencode/opencode.go` compared `interface{}` values directly inside `mapsEqualShallow`, which panicked when preserved `provider.olpx.models.<alias>` metadata included slices.
|
||||
- Root cause: `internal/opencode/opencode.go` compared `interface{}` values directly inside `mapsEqualShallow`, which panicked when preserved `provider.ocswitch.models.<alias>` metadata included slices.
|
||||
- Fix: replaced the unsafe hand-rolled comparison with `reflect.DeepEqual` so existing model metadata with nested maps/slices can be compared safely during `sync`.
|
||||
- Added regression tests in `internal/opencode/opencode_test.go` and `internal/cli/cli_test.go` covering preserved slice metadata and a real `opencode sync --target` no-op path.
|
||||
- Verification: ran `rtk go test ./internal/opencode`, `rtk go test ./internal/cli ./internal/opencode`, and `rtk go test ./...` successfully.
|
||||
|
||||
174
README.md
174
README.md
@ -1,13 +1,13 @@
|
||||
# opencode-provider-switch (`olpx`)
|
||||
# opencode-provider-switch (`ocswitch`)
|
||||
|
||||
English README: `README_EN.md`
|
||||
|
||||
`olpx` 是 OpenCode LocalProxy CLI,给 OpenCode 使用的本地代理。
|
||||
`ocswitch` 是 OpenCode Provider Switch CLI,给 OpenCode 使用的本地代理。
|
||||
|
||||
它解决的问题很简单:
|
||||
|
||||
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `olpx/gpt-5.4`
|
||||
- `olpx` 在本地把这个别名映射到多个上游 `provider/model`
|
||||
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `ocswitch/gpt-5.4`
|
||||
- `ocswitch` 在本地把这个别名映射到多个上游 `provider/model`
|
||||
- 按你配置的顺序依次尝试上游
|
||||
- 如果主上游在响应开始前失败,自动切到下一个上游
|
||||
|
||||
@ -16,17 +16,17 @@ English README: `README_EN.md`
|
||||
## 适合什么场景
|
||||
|
||||
- 你有多个 OpenAI 兼容上游
|
||||
- 你不想在 OpenCode 里频繁切换 provider
|
||||
- 你不想在 OpenCode 里频繁切换 provider 或模型入口
|
||||
- 你希望用一个固定别名承接多个备用上游
|
||||
- 你希望失败切换行为是确定的、可预期的
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 本地维护 `olpx` 配置文件:上游 provider、alias、监听地址
|
||||
- 本地维护 `ocswitch` 配置文件:上游 provider、alias、监听地址
|
||||
- 支持手动添加 provider
|
||||
- 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider
|
||||
- 支持创建 alias,并按顺序绑定多个上游 target
|
||||
- 支持把 alias 同步到 OpenCode 的 `provider.olpx.models`
|
||||
- 支持把 alias 同步到 OpenCode 的 `provider.ocswitch.models`
|
||||
- 支持本地代理 `POST /v1/responses`
|
||||
- 支持流式透传
|
||||
- 支持首字节前失败切换
|
||||
@ -45,30 +45,30 @@ English README: `README_EN.md`
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go build -o olpx ./cmd/olpx
|
||||
go build -o ocswitch ./cmd/ocswitch
|
||||
```
|
||||
|
||||
如果你只想临时运行,也可以直接:
|
||||
|
||||
```bash
|
||||
go run ./cmd/olpx --help
|
||||
go run ./cmd/ocswitch --help
|
||||
```
|
||||
|
||||
## 5 分钟快速上手
|
||||
|
||||
### 1. 添加上游 provider
|
||||
|
||||
`olpx` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。
|
||||
`ocswitch` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。
|
||||
|
||||
```bash
|
||||
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx
|
||||
olpx provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy
|
||||
ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx
|
||||
ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy
|
||||
```
|
||||
|
||||
如果某个上游还需要额外请求头,可以重复传 `--header`:
|
||||
|
||||
```bash
|
||||
olpx provider add \
|
||||
ocswitch provider add \
|
||||
--id relay \
|
||||
--base-url https://example.com/v1 \
|
||||
--api-key sk-zzz \
|
||||
@ -79,42 +79,42 @@ olpx provider add \
|
||||
查看当前 provider:
|
||||
|
||||
```bash
|
||||
olpx provider list
|
||||
ocswitch provider list
|
||||
```
|
||||
|
||||
### 2. 创建 alias,并绑定多个上游 target
|
||||
|
||||
下面这个例子表示:当你使用 `olpx/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4`。
|
||||
下面这个例子表示:当你使用 `ocswitch/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4`。
|
||||
|
||||
```bash
|
||||
olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
```
|
||||
|
||||
查看当前 alias:
|
||||
|
||||
```bash
|
||||
olpx alias list
|
||||
ocswitch alias list
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- target 的顺序就是失败切换顺序
|
||||
- enabled alias 必须至少有一个可路由 target
|
||||
- `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
||||
- `ocswitch alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
||||
|
||||
### 3. 先做一次静态检查
|
||||
|
||||
```bash
|
||||
olpx doctor
|
||||
ocswitch doctor
|
||||
```
|
||||
|
||||
`olpx doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
|
||||
`ocswitch doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
|
||||
|
||||
它会检查:
|
||||
|
||||
- 本地 `olpx` 配置能不能正常加载
|
||||
- 本地 `ocswitch` 配置能不能正常加载
|
||||
- alias 是否引用了不存在的 provider
|
||||
- enabled alias 是否至少有一个可路由 target
|
||||
- 本地代理监听地址是否合理
|
||||
@ -123,56 +123,56 @@ olpx doctor
|
||||
### 4. 把 alias 同步到 OpenCode
|
||||
|
||||
```bash
|
||||
olpx opencode sync
|
||||
ocswitch opencode sync
|
||||
```
|
||||
|
||||
这个命令会做一件事:把当前可路由的 alias 列表同步进 OpenCode 的 `provider.olpx.models`。
|
||||
这个命令会做一件事:把当前可路由的 alias 列表同步进 OpenCode 的 `provider.ocswitch.models`。
|
||||
|
||||
默认行为:
|
||||
|
||||
- 优先复用全局 OpenCode 配置文件:`opencode.jsonc` > `opencode.json` > `config.json`
|
||||
- 如果都不存在,就创建 `~/.config/opencode/opencode.jsonc`
|
||||
- 默认目标明确只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR`
|
||||
- 只更新 `provider.olpx`
|
||||
- 只更新 `provider.ocswitch`
|
||||
- 不会修改顶层 `model`
|
||||
- 不会修改顶层 `small_model`
|
||||
|
||||
如果你希望顺手把默认模型也切到 `olpx`,需要显式指定:
|
||||
如果你希望顺手把默认模型也切到 `ocswitch`,需要显式指定:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --set-model olpx/gpt-5.4
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4
|
||||
```
|
||||
|
||||
如果你还有小模型 alias,也可以这样:
|
||||
|
||||
```bash
|
||||
olpx opencode sync \
|
||||
--set-model olpx/gpt-5.4 \
|
||||
--set-small-model olpx/gpt-5.4-mini
|
||||
ocswitch opencode sync \
|
||||
--set-model ocswitch/gpt-5.4 \
|
||||
--set-small-model ocswitch/gpt-5.4-mini
|
||||
```
|
||||
|
||||
先预览不写入:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --dry-run
|
||||
ocswitch opencode sync --dry-run
|
||||
```
|
||||
|
||||
写到指定 OpenCode 配置文件:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --target /path/to/opencode.jsonc
|
||||
ocswitch opencode sync --target /path/to/opencode.jsonc
|
||||
```
|
||||
|
||||
### 5. 启动本地代理
|
||||
|
||||
```bash
|
||||
olpx serve
|
||||
ocswitch serve
|
||||
```
|
||||
|
||||
默认监听地址:
|
||||
|
||||
- `127.0.0.1:9982`
|
||||
- 本地 API Key:`olpx-local`
|
||||
- 本地 API Key:`ocswitch-local`
|
||||
|
||||
启动后,本地代理地址是:
|
||||
|
||||
@ -182,47 +182,47 @@ http://127.0.0.1:9982/v1
|
||||
|
||||
### 6. 在 OpenCode 里使用
|
||||
|
||||
完成 `olpx opencode sync` 后,你应该能在 OpenCode 里看到 `olpx/<alias>`。
|
||||
完成 `ocswitch opencode sync` 后,你应该能在 OpenCode 里看到 `ocswitch/<alias>`。
|
||||
|
||||
例如:
|
||||
|
||||
- `olpx/gpt-5.4`
|
||||
- `ocswitch/gpt-5.4`
|
||||
|
||||
如果你执行了:
|
||||
|
||||
```bash
|
||||
olpx opencode sync --set-model olpx/gpt-5.4
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4
|
||||
```
|
||||
|
||||
那么 OpenCode 默认模型也会直接切到这个 alias。
|
||||
|
||||
## 直接验证本地代理
|
||||
|
||||
如果你想先不走 OpenCode,直接验证 `olpx` 是否能正常代理,可以自己发一个请求:
|
||||
如果你想先不走 OpenCode,直接验证 `ocswitch` 是否能正常代理,可以自己发一个请求:
|
||||
|
||||
```bash
|
||||
curl -sN -X POST http://127.0.0.1:9982/v1/responses \
|
||||
-H "Authorization: Bearer olpx-local" \
|
||||
-H "Authorization: Bearer ocswitch-local" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-5.4","stream":true,"input":"hello"}'
|
||||
```
|
||||
|
||||
注意这里请求体里的 `model` 是 alias 本身,例如 `gpt-5.4`,不是 `olpx/gpt-5.4`。
|
||||
注意这里请求体里的 `model` 是 alias 本身,例如 `gpt-5.4`,不是 `ocswitch/gpt-5.4`。
|
||||
|
||||
因为 `olpx/gpt-5.4` 是 OpenCode 侧的模型选择写法;真正发到本地 provider 的请求里,模型名会是 alias 自身。
|
||||
因为 `ocswitch/gpt-5.4` 是 OpenCode 侧的模型选择写法;真正发到本地 provider 的请求里,模型名会是 alias 自身。
|
||||
|
||||
## 从现有 OpenCode 配置导入 provider
|
||||
|
||||
如果你原来已经在 OpenCode 里配置过一些自定义 provider,可以直接导入:
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode
|
||||
ocswitch provider import-opencode
|
||||
```
|
||||
|
||||
或者指定导入源:
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode --from ./examples/opencode.jsonc
|
||||
ocswitch provider import-opencode --from ./examples/opencode.jsonc
|
||||
```
|
||||
|
||||
支持范围只有这一类:
|
||||
@ -237,13 +237,13 @@ olpx provider import-opencode --from ./examples/opencode.jsonc
|
||||
- 默认导入源也只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR`
|
||||
- 如果你要导入别的 OpenCode 配置文件,请显式传 `--from`
|
||||
- 当前只导入 provider 的基本连接信息
|
||||
- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `olpx provider add --header ...` 补齐
|
||||
- `olpx` 自己不会被反向导入
|
||||
- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `ocswitch provider add --header ...` 补齐
|
||||
- `ocswitch` 自己不会被反向导入
|
||||
|
||||
覆盖已存在的 provider:
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode --overwrite
|
||||
ocswitch provider import-opencode --overwrite
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
@ -253,65 +253,65 @@ olpx provider import-opencode --overwrite
|
||||
添加或更新 provider:
|
||||
|
||||
```bash
|
||||
olpx provider add --id <id> --base-url <url-with-/v1> --api-key <key>
|
||||
ocswitch provider add --id <id> --base-url <url-with-/v1> --api-key <key>
|
||||
```
|
||||
|
||||
查看 provider:
|
||||
|
||||
```bash
|
||||
olpx provider list
|
||||
ocswitch provider list
|
||||
```
|
||||
|
||||
禁用 provider:
|
||||
|
||||
```bash
|
||||
olpx provider disable <id>
|
||||
ocswitch provider disable <id>
|
||||
```
|
||||
|
||||
重新启用 provider:
|
||||
|
||||
```bash
|
||||
olpx provider enable <id>
|
||||
ocswitch provider enable <id>
|
||||
```
|
||||
|
||||
删除 provider:
|
||||
|
||||
```bash
|
||||
olpx provider remove <id>
|
||||
ocswitch provider remove <id>
|
||||
```
|
||||
|
||||
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`olpx doctor` 会报错。
|
||||
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`ocswitch doctor` 会报错。
|
||||
|
||||
### alias
|
||||
|
||||
创建或更新 alias:
|
||||
|
||||
```bash
|
||||
olpx alias add --name <alias>
|
||||
ocswitch alias add --name <alias>
|
||||
```
|
||||
|
||||
给 alias 追加一个 target:
|
||||
|
||||
```bash
|
||||
olpx alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
|
||||
ocswitch alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
|
||||
```
|
||||
|
||||
解绑 target:
|
||||
|
||||
```bash
|
||||
olpx alias unbind --alias <alias> --provider <provider-id> --model <upstream-model>
|
||||
ocswitch alias unbind --alias <alias> --provider <provider-id> --model <upstream-model>
|
||||
```
|
||||
|
||||
查看 alias:
|
||||
|
||||
```bash
|
||||
olpx alias list
|
||||
ocswitch alias list
|
||||
```
|
||||
|
||||
删除 alias:
|
||||
|
||||
```bash
|
||||
olpx alias remove <alias>
|
||||
ocswitch alias remove <alias>
|
||||
```
|
||||
|
||||
### 其他
|
||||
@ -319,42 +319,42 @@ olpx alias remove <alias>
|
||||
静态检查:
|
||||
|
||||
```bash
|
||||
olpx doctor
|
||||
ocswitch doctor
|
||||
```
|
||||
|
||||
启动代理:
|
||||
|
||||
```bash
|
||||
olpx serve
|
||||
ocswitch serve
|
||||
```
|
||||
|
||||
同步到 OpenCode:
|
||||
|
||||
```bash
|
||||
olpx opencode sync
|
||||
ocswitch opencode sync
|
||||
```
|
||||
|
||||
全局帮助:
|
||||
|
||||
```bash
|
||||
olpx --help
|
||||
olpx provider --help
|
||||
olpx alias --help
|
||||
olpx opencode sync --help
|
||||
ocswitch --help
|
||||
ocswitch provider --help
|
||||
ocswitch alias --help
|
||||
ocswitch opencode sync --help
|
||||
```
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
本地 `olpx` 配置文件默认路径:
|
||||
本地 `ocswitch` 配置文件默认路径:
|
||||
|
||||
- 如果设置了 `OLPX_CONFIG`,优先使用它
|
||||
- 否则使用 `$XDG_CONFIG_HOME/olpx/config.json`
|
||||
- 再否则使用 `~/.config/olpx/config.json`
|
||||
- 如果设置了 `OCSWITCH_CONFIG`,优先使用它
|
||||
- 否则使用 `$XDG_CONFIG_HOME/ocswitch/config.json`
|
||||
- 再否则使用 `~/.config/ocswitch/config.json`
|
||||
|
||||
也可以对每个命令显式指定:
|
||||
|
||||
```bash
|
||||
olpx --config /path/to/config.json doctor
|
||||
ocswitch --config /path/to/config.json doctor
|
||||
```
|
||||
|
||||
命令级行为、默认值、写入范围与副作用,以对应命令的 `--help` 为准;README 主要保留快速上手与背景说明。
|
||||
@ -366,7 +366,7 @@ olpx --config /path/to/config.json doctor
|
||||
"server": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9982,
|
||||
"api_key": "olpx-local"
|
||||
"api_key": "ocswitch-local"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
@ -407,7 +407,7 @@ olpx --config /path/to/config.json doctor
|
||||
|
||||
## 失败切换规则
|
||||
|
||||
`olpx` 的切换规则很保守,也很容易理解。
|
||||
`ocswitch` 的切换规则很保守,也很容易理解。
|
||||
|
||||
会切换到下一个 target 的情况:
|
||||
|
||||
@ -438,11 +438,11 @@ olpx --config /path/to/config.json doctor
|
||||
|
||||
每次成功代理或透传上游错误时,响应里都会附带这些头:
|
||||
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
你可以用它们确认:
|
||||
|
||||
@ -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 状态
|
||||
3. alias 是否至少绑定了一个可路由 target
|
||||
4. alias 绑定的 provider 是否都被禁用了
|
||||
5. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件
|
||||
6. 执行一次 `olpx doctor`,看输出里的 `opencode config target`
|
||||
5. OpenCode 当前实际使用的配置文件,是否就是 `ocswitch opencode sync` 写入的那个文件
|
||||
6. 执行一次 `ocswitch doctor`,看输出里的 `opencode config target`
|
||||
|
||||
### 为什么 `olpx doctor` 报 alias 没有可用 target?
|
||||
### 为什么 `ocswitch doctor` 报 alias 没有可用 target?
|
||||
|
||||
因为当前实现要求:只要 alias 是 enabled,就必须至少有一个可路由的 target。
|
||||
|
||||
@ -484,14 +484,14 @@ olpx --config /path/to/config.json doctor
|
||||
|
||||
因为 provider 可能被多个 alias 复用。
|
||||
|
||||
`olpx provider disable` 只会让路由层在 failover 时自动跳过这个 provider,不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。
|
||||
`ocswitch provider disable` 只会让路由层在 failover 时自动跳过这个 provider,不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。
|
||||
|
||||
### 删除 provider 后为什么还有报错?
|
||||
|
||||
因为 alias 里的 target 还是旧引用。需要继续执行:
|
||||
|
||||
```bash
|
||||
olpx alias unbind --alias <alias> --provider <provider-id> --model <model>
|
||||
ocswitch alias unbind --alias <alias> --provider <provider-id> --model <model>
|
||||
```
|
||||
|
||||
### 本地代理鉴权是什么?
|
||||
@ -499,15 +499,15 @@ olpx alias unbind --alias <alias> --provider <provider-id> --model <model>
|
||||
默认是静态 key:
|
||||
|
||||
```text
|
||||
olpx-local
|
||||
ocswitch-local
|
||||
```
|
||||
|
||||
OpenCode 在 `provider.olpx.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
|
||||
OpenCode 在 `provider.ocswitch.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 默认只监听 `127.0.0.1`
|
||||
- 上游凭据保存在本地 `olpx` 配置文件中
|
||||
- 上游凭据保存在本地 `ocswitch` 配置文件中
|
||||
- 本项目当前没有做多用户或远程网络安全保证
|
||||
|
||||
所以请把本地配置文件当成敏感文件处理。
|
||||
|
||||
62
README_EN.md
62
README_EN.md
@ -1,15 +1,15 @@
|
||||
# opencode-provider-switch (`olpx`)
|
||||
# opencode-provider-switch (`ocswitch`)
|
||||
|
||||
A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one
|
||||
stable model alias** routed to **multiple upstream providers** with
|
||||
**deterministic failover**.
|
||||
|
||||
- Expose one custom provider `olpx` to OpenCode.
|
||||
- Configure logical aliases (`olpx/gpt-5.4`, etc.).
|
||||
- Expose one custom provider `ocswitch` to OpenCode.
|
||||
- Configure logical aliases (`ocswitch/gpt-5.4`, etc.).
|
||||
- Each alias has an ordered list of upstream `provider/model` targets.
|
||||
- Providers can be disabled without mutating alias target state.
|
||||
- When the primary upstream returns `5xx`/`429`/connect error *before* any
|
||||
stream bytes are flushed, `olpx` transparently retries the next target.
|
||||
stream bytes are flushed, `ocswitch` transparently retries the next target.
|
||||
- Once a stream has started, the upstream is locked for the rest of that
|
||||
request — no mid-stream splicing.
|
||||
|
||||
@ -18,38 +18,38 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go build -o olpx ./cmd/olpx
|
||||
go build -o ocswitch ./cmd/ocswitch
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. add upstream providers
|
||||
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
|
||||
olpx provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-...
|
||||
ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
|
||||
ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-...
|
||||
|
||||
# 2. create alias and bind targets in priority order
|
||||
olpx alias add --name gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
ocswitch alias add --name gpt-5.4
|
||||
ocswitch alias bind --alias gpt-5.4 --provider su8 --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
|
||||
olpx opencode sync
|
||||
ocswitch opencode sync
|
||||
|
||||
# optional: temporarily disable one provider without editing alias targets
|
||||
olpx provider disable su8
|
||||
ocswitch provider disable su8
|
||||
|
||||
# 4. run the proxy
|
||||
olpx serve
|
||||
ocswitch serve
|
||||
```
|
||||
|
||||
Inside OpenCode you can now pick `olpx/gpt-5.4`.
|
||||
Inside OpenCode you can now pick `ocswitch/gpt-5.4`.
|
||||
|
||||
### Import providers from an existing OpenCode config
|
||||
|
||||
```bash
|
||||
olpx provider import-opencode # reads global OpenCode config
|
||||
olpx provider import-opencode --from ./examples/opencode.jsonc
|
||||
ocswitch provider import-opencode # reads global OpenCode config
|
||||
ocswitch provider import-opencode --from ./examples/opencode.jsonc
|
||||
```
|
||||
|
||||
The default import/sync target is the global user config only. It does not
|
||||
@ -62,7 +62,7 @@ imported. Everything else is out of MVP scope.
|
||||
### Doctor (static)
|
||||
|
||||
```bash
|
||||
olpx doctor
|
||||
ocswitch doctor
|
||||
```
|
||||
|
||||
Runs structural checks only — never issues real upstream requests.
|
||||
@ -75,14 +75,14 @@ considered routable only when:
|
||||
- the referenced provider exists
|
||||
- the referenced provider is not disabled
|
||||
|
||||
`olpx opencode sync` and `/v1/models` use the same routable-alias view, so
|
||||
`ocswitch opencode sync` and `/v1/models` use the same routable-alias view, so
|
||||
OpenCode does not see aliases that the proxy would immediately reject.
|
||||
|
||||
### Provider state
|
||||
|
||||
```bash
|
||||
olpx provider disable <id>
|
||||
olpx provider enable <id>
|
||||
ocswitch provider disable <id>
|
||||
ocswitch provider enable <id>
|
||||
```
|
||||
|
||||
Disabling a provider only removes it from routing/failover consideration. It
|
||||
@ -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
|
||||
is the authoritative local execution contract.
|
||||
|
||||
- `olpx serve` — run the proxy
|
||||
- `olpx doctor` — validate config
|
||||
- `olpx provider {add,list,enable,disable,remove,import-opencode}`
|
||||
- `olpx alias {add,list,bind,unbind,remove}`
|
||||
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
|
||||
- `ocswitch serve` — run the proxy
|
||||
- `ocswitch doctor` — validate config
|
||||
- `ocswitch provider {add,list,enable,disable,remove,import-opencode}`
|
||||
- `ocswitch alias {add,list,bind,unbind,remove}`
|
||||
- `ocswitch opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
|
||||
|
||||
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
|
||||
|
||||
Every proxied response includes:
|
||||
|
||||
- `X-OLPX-Alias`
|
||||
- `X-OLPX-Provider`
|
||||
- `X-OLPX-Remote-Model`
|
||||
- `X-OLPX-Attempt`
|
||||
- `X-OLPX-Failover-Count`
|
||||
- `X-OCSWITCH-Alias`
|
||||
- `X-OCSWITCH-Provider`
|
||||
- `X-OCSWITCH-Remote-Model`
|
||||
- `X-OCSWITCH-Attempt`
|
||||
- `X-OCSWITCH-Failover-Count`
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
// Command olpx: local alias + failover proxy for OpenCode.
|
||||
// Command ocswitch: local alias + failover proxy for OpenCode.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/cli"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/cli"
|
||||
)
|
||||
|
||||
// version is overridden at build time via -ldflags "-X main.version=...".
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
// Sanitized example for `olpx provider import-opencode --from ./examples/opencode.jsonc`
|
||||
// Sanitized example for `ocswitch provider import-opencode --from ./examples/opencode.jsonc`
|
||||
"model": "su8/gpt-5.4",
|
||||
"small_model": "codex/GPT-5.4-mini",
|
||||
"provider": {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
{
|
||||
// provider.olpx.models entries may include OpenCode-only metadata.
|
||||
// olpx sync preserves same-name model objects and only manages the alias set.
|
||||
// provider.ocswitch.models entries may include OpenCode-only metadata.
|
||||
// ocswitch sync preserves same-name model objects and only manages the alias set.
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "olpx/gpt-5.4",
|
||||
"small_model": "olpx/gpt-5.4-mini",
|
||||
"model": "ocswitch/gpt-5.4",
|
||||
"small_model": "ocswitch/gpt-5.4-mini",
|
||||
"provider": {
|
||||
"olpx": {
|
||||
"ocswitch": {
|
||||
"models": {
|
||||
"gpt-5.4": {
|
||||
"name": "gpt-5.4",
|
||||
@ -50,10 +50,10 @@
|
||||
"name": "gpt-5.4-mini"
|
||||
}
|
||||
},
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": "OpenCode Provider Switch CLI",
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"setCacheKey": true
|
||||
}
|
||||
2
go.mod
2
go.mod
@ -1,4 +1,4 @@
|
||||
module github.com/anomalyco/opencode-provider-switch
|
||||
module github.com/Apale7/opencode-provider-switch
|
||||
|
||||
go 1.22.2
|
||||
|
||||
|
||||
@ -7,26 +7,26 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
func newAliasCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "alias",
|
||||
Short: "Manage logical aliases routed by olpx",
|
||||
Short: "Manage logical aliases routed by ocswitch",
|
||||
Long: `Alias commands manage the user-facing model names that OpenCode sees as
|
||||
olpx/<alias>.
|
||||
ocswitch/<alias>.
|
||||
|
||||
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.
|
||||
|
||||
Common workflow: create an alias, bind primary and fallback targets, inspect the
|
||||
result with alias list, then run doctor and opencode sync.`,
|
||||
Example: ` olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
olpx alias list`,
|
||||
Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
ocswitch alias list`,
|
||||
}
|
||||
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
|
||||
return c
|
||||
@ -38,7 +38,7 @@ func newAliasAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Create or update an alias (without targets)",
|
||||
Long: `alias add creates or updates alias metadata in local 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.
|
||||
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
|
||||
value and existing targets stay attached. Typical next step: add targets with
|
||||
olpx alias bind.`,
|
||||
Example: ` olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
olpx alias add --name gpt-5.4-mini --disabled
|
||||
olpx alias add --name gpt-5.4 --display-name "GPT 5.4 Reasoning"`,
|
||||
ocswitch alias bind.`,
|
||||
Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
ocswitch alias add --name gpt-5.4-mini --disabled
|
||||
ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4 Reasoning"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("--name is required")
|
||||
@ -78,7 +78,7 @@ olpx alias bind.`,
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as olpx/<name> in OpenCode (required)")
|
||||
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as ocswitch/<name> in OpenCode (required)")
|
||||
cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name")
|
||||
cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state")
|
||||
return cmd
|
||||
@ -88,7 +88,7 @@ func newAliasListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
This command does not modify config and does not contact upstream providers.`,
|
||||
Example: ` olpx alias list
|
||||
olpx --config /path/to/config.json alias list`,
|
||||
Example: ` ocswitch alias list
|
||||
ocswitch --config /path/to/config.json alias list`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -143,7 +143,7 @@ func newAliasBindCmd() *cobra.Command {
|
||||
Use: "bind",
|
||||
Short: "Append a target (provider/model) to an alias in failover order",
|
||||
Long: `alias bind appends one provider/model target to an alias's ordered failover
|
||||
chain in local olpx config.
|
||||
chain in local ocswitch config.
|
||||
|
||||
The provider must already exist. If the alias does not exist yet, this command
|
||||
auto-creates an enabled alias for convenience. 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,
|
||||
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
|
||||
olpx 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`,
|
||||
Example: ` ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
ocswitch alias bind --alias gpt-5.4 --provider relay --model gpt-5.4 --disabled`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if alias == "" || provider == "" || model == "" {
|
||||
return fmt.Errorf("--alias, --provider and --model are required")
|
||||
@ -192,7 +192,7 @@ func newAliasUnbindCmd() *cobra.Command {
|
||||
Use: "unbind",
|
||||
Short: "Remove a target from an alias",
|
||||
Long: `alias unbind removes one concrete provider/model target tuple from an alias in
|
||||
local olpx config.
|
||||
local ocswitch config.
|
||||
|
||||
It does not delete the alias itself. Removing a target can leave the alias with
|
||||
no routable targets, which doctor and opencode sync will then treat as invalid
|
||||
@ -200,8 +200,8 @@ or unavailable.
|
||||
|
||||
Typical next step: run alias list or doctor to confirm the remaining target
|
||||
chain.`,
|
||||
Example: ` olpx alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
olpx doctor`,
|
||||
Example: ` ocswitch alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||
ocswitch doctor`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if alias == "" || provider == "" || model == "" {
|
||||
return fmt.Errorf("--alias, --provider and --model are required")
|
||||
@ -231,16 +231,16 @@ func newAliasRemoveCmd() *cobra.Command {
|
||||
Use: "remove <alias>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Delete an alias entirely",
|
||||
Long: `alias remove deletes one alias and all of its target bindings from local olpx
|
||||
Long: `alias remove deletes one alias and all of its target bindings from local ocswitch
|
||||
config.
|
||||
|
||||
Future opencode sync runs will stop exposing that alias in provider.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
|
||||
reference the old alias in OpenCode config.
|
||||
|
||||
Typical next step: run olpx opencode sync if OpenCode exposure should be updated.`,
|
||||
Example: ` olpx alias remove gpt-5.4
|
||||
olpx opencode sync`,
|
||||
Typical next step: run ocswitch opencode sync if OpenCode exposure should be updated.`,
|
||||
Example: ` ocswitch alias remove gpt-5.4
|
||||
ocswitch opencode sync`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
|
||||
@ -9,11 +9,11 @@ import (
|
||||
|
||||
"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) {
|
||||
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json"))
|
||||
t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json"))
|
||||
configPath = ""
|
||||
|
||||
cfg, err := loadCfg()
|
||||
@ -64,8 +64,8 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
|
||||
configFile := filepath.Join(t.TempDir(), "olpx.json")
|
||||
t.Setenv("OLPX_CONFIG", configFile)
|
||||
configFile := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
t.Setenv(config.ConfigEnvVar, configFile)
|
||||
configPath = ""
|
||||
|
||||
cmd := newProviderAddCmd()
|
||||
@ -83,7 +83,7 @@ func TestProviderAddRejectsInvalidBaseURL(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 = ""
|
||||
|
||||
cfg, err := loadCfg()
|
||||
@ -126,7 +126,7 @@ func TestAliasAddPreservesExistingFields(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 = ""
|
||||
|
||||
cfg, err := loadCfg()
|
||||
@ -171,7 +171,7 @@ func TestProviderEnableDisableCommands(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 = ""
|
||||
|
||||
cfg, err := loadCfg()
|
||||
@ -192,7 +192,7 @@ func TestOpencodeSyncDoesNotPanicOnSliceModelMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("write target config: %v", err)
|
||||
}
|
||||
@ -235,13 +235,13 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) {
|
||||
name: "root",
|
||||
cmd: NewRootCmd("test"),
|
||||
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",
|
||||
cmd: newProviderAddCmd(),
|
||||
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",
|
||||
@ -252,8 +252,8 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) {
|
||||
{
|
||||
name: "opencode sync",
|
||||
cmd: newOpencodeSyncCmd(),
|
||||
wantLong: []string{"does not follow", "writes alias", "exposure into provider.olpx.models"},
|
||||
wantExample: []string{"--dry-run", "--set-small-model olpx/gpt-5.4-mini"},
|
||||
wantLong: []string{"does not follow", "writes alias", "exposure into provider.ocswitch.models"},
|
||||
wantExample: []string{"--dry-run", "--set-small-model ocswitch/gpt-5.4-mini"},
|
||||
},
|
||||
}
|
||||
|
||||
@ -283,7 +283,7 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) {
|
||||
if flag == nil {
|
||||
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) {
|
||||
t.Fatalf("config flag usage missing %q: %s", want, flag.Usage)
|
||||
}
|
||||
|
||||
@ -5,25 +5,25 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func newDoctorCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Validate olpx config (static checks, no upstream requests)",
|
||||
Long: `doctor performs static validation for local olpx config and the expected
|
||||
Short: "Validate ocswitch config (static checks, no upstream requests)",
|
||||
Long: `doctor performs static validation for local ocswitch config and the expected
|
||||
OpenCode sync result.
|
||||
|
||||
It loads local olpx config, checks alias/provider consistency, resolves the
|
||||
default OpenCode sync target, and validates what provider.olpx would look like
|
||||
It loads local ocswitch config, checks alias/provider consistency, resolves the
|
||||
default OpenCode sync target, and validates what provider.ocswitch would look like
|
||||
there. It does not send any real requests to upstream providers and does not
|
||||
consume model quota.
|
||||
|
||||
Run doctor before opencode sync or serve whenever you changed providers,
|
||||
aliases, or local server settings.`,
|
||||
Example: ` olpx doctor
|
||||
olpx --config /path/to/config.json doctor`,
|
||||
Example: ` ocswitch doctor
|
||||
ocswitch --config /path/to/config.json doctor`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -38,9 +38,9 @@ aliases, or local server settings.`,
|
||||
} else {
|
||||
aliasNames := cfg.AvailableAliasNames()
|
||||
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
||||
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
||||
issues = append(issues, fmt.Errorf("opencode provider.olpx invalid: %w", err))
|
||||
opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||
if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
||||
issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err))
|
||||
}
|
||||
}
|
||||
ok := len(issues) == 0
|
||||
@ -61,7 +61,7 @@ aliases, or local server settings.`,
|
||||
marker = "(exists)"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " provider.olpx preview: valid=%v\n", ok)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", ok)
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||
if !ok {
|
||||
|
||||
@ -5,24 +5,24 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func newOpencodeCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "opencode",
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Common workflow: validate with doctor first, inspect sync help, then run
|
||||
opencode sync.`,
|
||||
Example: ` olpx opencode sync --dry-run
|
||||
olpx opencode sync --set-model olpx/gpt-5.4`,
|
||||
Example: ` ocswitch opencode sync --dry-run
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4`,
|
||||
}
|
||||
c.AddCommand(newOpencodeSyncCmd())
|
||||
return c
|
||||
@ -35,8 +35,8 @@ func newOpencodeSyncCmd() *cobra.Command {
|
||||
var dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Update provider.olpx in the global OpenCode config to match current aliases",
|
||||
Long: `olpx opencode sync writes provider.olpx into the target OpenCode config.
|
||||
Short: "Update provider.ocswitch in the global OpenCode config to match current aliases",
|
||||
Long: `ocswitch opencode sync writes provider.ocswitch into the target OpenCode config.
|
||||
|
||||
By default it targets the global user config (~/.config/opencode), picking the
|
||||
existing file in precedence order opencode.jsonc > opencode.json > config.json,
|
||||
@ -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
|
||||
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.
|
||||
|
||||
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.`,
|
||||
Example: ` olpx opencode sync
|
||||
olpx opencode sync --dry-run
|
||||
olpx opencode sync --set-model olpx/gpt-5.4
|
||||
olpx opencode sync --set-model olpx/gpt-5.4 --set-small-model olpx/gpt-5.4-mini
|
||||
olpx opencode sync --target /path/to/opencode.jsonc`,
|
||||
Example: ` ocswitch opencode sync
|
||||
ocswitch opencode sync --dry-run
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4
|
||||
ocswitch opencode sync --set-model ocswitch/gpt-5.4 --set-small-model ocswitch/gpt-5.4-mini
|
||||
ocswitch opencode sync --target /path/to/opencode.jsonc`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -78,7 +78,7 @@ needed.`,
|
||||
}
|
||||
aliasNames := cfg.AvailableAliasNames()
|
||||
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
||||
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||
changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||
if setModel != "" {
|
||||
if raw["model"] != setModel {
|
||||
raw["model"] = setModel
|
||||
@ -102,7 +102,7 @@ needed.`,
|
||||
if err := opencode.Save(path, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.olpx into %s (%d alias(es))\n", path, len(aliasNames))
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", path, len(aliasNames))
|
||||
if setModel != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/opencode"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func newProviderCmd() *cobra.Command {
|
||||
@ -16,17 +16,17 @@ func newProviderCmd() *cobra.Command {
|
||||
Use: "provider",
|
||||
Short: "Manage upstream providers",
|
||||
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
|
||||
as base URL, API key, and extra headers, while aliases decide failover order by
|
||||
binding one or more provider/model targets.
|
||||
|
||||
Common workflow: add or import providers first, inspect them with provider list,
|
||||
then bind them to aliases with olpx alias bind.`,
|
||||
Example: ` olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
|
||||
olpx provider import-opencode
|
||||
olpx provider list`,
|
||||
then bind them to aliases with ocswitch alias bind.`,
|
||||
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
|
||||
ocswitch provider import-opencode
|
||||
ocswitch provider list`,
|
||||
}
|
||||
c.AddCommand(
|
||||
newProviderAddCmd(),
|
||||
@ -46,10 +46,10 @@ func newProviderAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
stored header map for this command invocation.
|
||||
|
||||
Typical next step: run olpx provider list or bind the provider to an alias.`,
|
||||
Example: ` olpx 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
|
||||
olpx 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`,
|
||||
Typical next step: run ocswitch provider list or bind the provider to an alias.`,
|
||||
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1
|
||||
ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
|
||||
ocswitch provider add --id relay --base-url https://example.com/v1 --api-key sk-example --header X-Token=abc --header X-Workspace=my-team
|
||||
ocswitch provider add --id su8 --base-url https://new.example.com/v1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if id == "" || baseURL == "" {
|
||||
return fmt.Errorf("--id and --base-url are required")
|
||||
@ -140,15 +140,15 @@ func newProviderListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
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
|
||||
redacted API keys are shown so you can confirm what was saved or imported before
|
||||
binding aliases.
|
||||
|
||||
This command does not modify config and does not contact upstream providers.`,
|
||||
Example: ` olpx provider list
|
||||
olpx --config /path/to/config.json provider list`,
|
||||
Example: ` ocswitch provider list
|
||||
ocswitch --config /path/to/config.json provider list`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -193,16 +193,16 @@ func newProviderStateCmd(use string, disabled bool) *cobra.Command {
|
||||
Use: use + " <id>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state",
|
||||
Long: fmt.Sprintf(`provider %s flips one provider's disabled state in local 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
|
||||
provider, but it does not rewrite alias target enabled flags. This matters when
|
||||
the same provider is shared across multiple aliases.
|
||||
|
||||
This command writes only the olpx config file and does not test upstream
|
||||
reachability. Typical next step: run olpx doctor to confirm routable aliases.`, use),
|
||||
Example: fmt.Sprintf(` olpx provider %s <id>
|
||||
olpx doctor`, use),
|
||||
This command writes only the ocswitch config file and does not test upstream
|
||||
reachability. Typical next step: run ocswitch doctor to confirm routable aliases.`, use),
|
||||
Example: fmt.Sprintf(` ocswitch provider %s <id>
|
||||
ocswitch doctor`, use),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -229,16 +229,16 @@ func newProviderRemoveCmd() *cobra.Command {
|
||||
Use: "remove <id>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)",
|
||||
Long: `provider remove deletes one provider from local 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
|
||||
removed provider. If aliases still reference it, doctor will report invalid
|
||||
config and those aliases will not be routable.
|
||||
|
||||
Typical follow-up: inspect aliases, unbind stale targets, then run olpx doctor.`,
|
||||
Example: ` olpx provider remove su8
|
||||
olpx alias list
|
||||
olpx doctor`,
|
||||
Typical follow-up: inspect aliases, unbind stale targets, then run ocswitch doctor.`,
|
||||
Example: ` ocswitch provider remove su8
|
||||
ocswitch alias list
|
||||
ocswitch doctor`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
@ -263,7 +263,7 @@ func newProviderImportCmd() *cobra.Command {
|
||||
Use: "import-opencode",
|
||||
Short: "Import @ai-sdk/openai custom providers from an OpenCode config file",
|
||||
Long: `provider import-opencode reads an OpenCode config file and copies supported
|
||||
custom providers into local olpx config.
|
||||
custom providers into local ocswitch config.
|
||||
|
||||
By default it reads the global user OpenCode config resolved in precedence order
|
||||
opencode.jsonc > opencode.json > config.json under ~/.config/opencode (XDG
|
||||
@ -271,13 +271,13 @@ aware). It does not follow OPENCODE_CONFIG_DIR for this default source; use
|
||||
--from when you want a different file.
|
||||
|
||||
Only config-defined @ai-sdk/openai custom providers with 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.
|
||||
|
||||
Typical next step: run olpx provider list, then create aliases and bindings.`,
|
||||
Example: ` olpx provider import-opencode
|
||||
olpx provider import-opencode --from /path/to/opencode.jsonc
|
||||
olpx provider import-opencode --overwrite`,
|
||||
Typical next step: run ocswitch provider list, then create aliases and bindings.`,
|
||||
Example: ` ocswitch provider import-opencode
|
||||
ocswitch provider import-opencode --from /path/to/opencode.jsonc
|
||||
ocswitch provider import-opencode --overwrite`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if srcPath == "" {
|
||||
p, existed := opencode.ResolveGlobalConfigPath()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Package cli wires the olpx cobra command tree.
|
||||
// Package cli wires the ocswitch cobra command tree.
|
||||
package cli
|
||||
|
||||
import (
|
||||
@ -7,50 +7,50 @@ import (
|
||||
|
||||
"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.
|
||||
var configPath string
|
||||
|
||||
// loadCfg opens the active olpx config, with the selected path.
|
||||
// loadCfg opens the active ocswitch config, with the selected path.
|
||||
func loadCfg() (*config.Config, error) {
|
||||
return config.Load(configPath)
|
||||
}
|
||||
|
||||
// NewRootCmd builds the root olpx command.
|
||||
// NewRootCmd builds the root ocswitch command.
|
||||
func NewRootCmd(version string) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "olpx",
|
||||
Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode",
|
||||
Long: `olpx is a local OpenCode proxy that exposes stable aliases as olpx/<alias>
|
||||
Use: config.AppName,
|
||||
Short: "OpenCode Provider Switch CLI: local alias + failover proxy for OpenCode",
|
||||
Long: `ocswitch is a local OpenCode proxy that exposes stable aliases as ocswitch/<alias>
|
||||
while routing each alias to one or more upstream provider/model targets.
|
||||
|
||||
Typical workflow:
|
||||
1. add or import upstream providers into local olpx config
|
||||
1. add or import upstream providers into local ocswitch config
|
||||
2. create aliases and bind ordered targets
|
||||
3. run doctor for static validation
|
||||
4. run opencode sync to write provider.olpx into OpenCode config
|
||||
4. run opencode sync to write provider.ocswitch into OpenCode config
|
||||
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,
|
||||
prefer command-local --help over README summaries.`,
|
||||
Example: ` olpx 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"
|
||||
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
olpx doctor
|
||||
olpx opencode sync
|
||||
olpx serve
|
||||
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
|
||||
ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||
ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
||||
ocswitch doctor
|
||||
ocswitch opencode sync
|
||||
ocswitch serve
|
||||
|
||||
olpx provider import-opencode
|
||||
olpx provider list
|
||||
olpx opencode sync --dry-run`,
|
||||
ocswitch provider import-opencode
|
||||
ocswitch provider list
|
||||
ocswitch opencode sync --dry-run`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: false,
|
||||
Version: version,
|
||||
}
|
||||
root.PersistentFlags().StringVar(&configPath, "config", "", "path to olpx config.json (default: $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(newDoctorCmd())
|
||||
|
||||
@ -6,24 +6,24 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/proxy"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/proxy"
|
||||
)
|
||||
|
||||
func newServeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Run the local olpx proxy (alias -> failover upstream)",
|
||||
Long: `serve starts the long-running local olpx proxy using the current local config.
|
||||
Short: "Run the local ocswitch proxy (alias -> failover upstream)",
|
||||
Long: `serve starts the long-running local ocswitch proxy using the current local config.
|
||||
|
||||
It reads local alias/provider configuration, validates that config, and then
|
||||
accepts OpenAI Responses traffic at the configured local base URL. With default
|
||||
settings the proxy listens on http://127.0.0.1:9982/v1 and expects the local API
|
||||
key olpx-local.
|
||||
key ocswitch-local.
|
||||
|
||||
serve does not rewrite config files. Run doctor and opencode sync first so
|
||||
OpenCode can see the same aliases that the proxy can route.`,
|
||||
Example: ` olpx serve
|
||||
olpx --config /path/to/config.json serve`,
|
||||
Example: ` ocswitch serve
|
||||
ocswitch --config /path/to/config.json serve`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Package config manages the local olpx JSON config file.
|
||||
// Package config manages the local ocswitch JSON config file.
|
||||
package config
|
||||
|
||||
import (
|
||||
@ -12,6 +12,13 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "ocswitch"
|
||||
ConfigEnvVar = "OCSWITCH_CONFIG"
|
||||
ConfigDirName = "ocswitch"
|
||||
DefaultLocalAPIKey = "ocswitch-local"
|
||||
)
|
||||
|
||||
// Target is one concrete upstream candidate for an alias.
|
||||
type Target struct {
|
||||
Provider string `json:"provider"`
|
||||
@ -44,7 +51,7 @@ type Server struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// Config is the on-disk olpx config.
|
||||
// Config is the on-disk ocswitch config.
|
||||
type Config struct {
|
||||
Server Server `json:"server"`
|
||||
Providers []Provider `json:"providers"`
|
||||
@ -78,26 +85,26 @@ func Default() *Config {
|
||||
Server: Server{
|
||||
Host: "127.0.0.1",
|
||||
Port: 9982,
|
||||
APIKey: "olpx-local",
|
||||
APIKey: DefaultLocalAPIKey,
|
||||
},
|
||||
Providers: []Provider{},
|
||||
Aliases: []Alias{},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPath returns ~/.config/olpx/config.json (XDG aware).
|
||||
// DefaultPath returns ~/.config/ocswitch/config.json (XDG aware).
|
||||
func DefaultPath() string {
|
||||
if p := os.Getenv("OLPX_CONFIG"); p != "" {
|
||||
if p := os.Getenv(ConfigEnvVar); p != "" {
|
||||
return p
|
||||
}
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "olpx", "config.json")
|
||||
return filepath.Join(xdg, ConfigDirName, "config.json")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "olpx-config.json"
|
||||
return AppName + "-config.json"
|
||||
}
|
||||
return filepath.Join(home, ".config", "olpx", "config.json")
|
||||
return filepath.Join(home, ".config", ConfigDirName, "config.json")
|
||||
}
|
||||
|
||||
// Load reads the config at path. Missing file returns a default config anchored to path.
|
||||
@ -127,7 +134,7 @@ func Load(path string) (*Config, error) {
|
||||
c.Server.Port = 9982
|
||||
}
|
||||
if c.Server.APIKey == "" {
|
||||
c.Server.APIKey = "olpx-local"
|
||||
c.Server.APIKey = DefaultLocalAPIKey
|
||||
}
|
||||
c.path = path
|
||||
return c, nil
|
||||
|
||||
@ -93,7 +93,7 @@ func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: "olpx-local"},
|
||||
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey},
|
||||
Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", Disabled: true}},
|
||||
Aliases: []Alias{{
|
||||
Alias: "gpt-5.4",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Package opencode reads and writes OpenCode config files, including the
|
||||
// `provider.olpx` sync path. Files may be JSON or JSONC; we preserve the
|
||||
// `provider.ocswitch` sync path. Files may be JSON or JSONC; we preserve the
|
||||
// detected extension on write.
|
||||
package opencode
|
||||
|
||||
@ -16,6 +16,11 @@ import (
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderKey = "ocswitch"
|
||||
ProviderName = "OpenCode Provider Switch CLI"
|
||||
)
|
||||
|
||||
// ConfigFileCandidates is the precedence order inside the global config dir.
|
||||
var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"}
|
||||
|
||||
@ -72,8 +77,8 @@ func Load(path string) (Raw, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save writes provider.olpx back to path. Existing files are normalized to plain
|
||||
// JSON and only the provider.olpx subtree is patched so unrelated key order stays
|
||||
// Save writes provider.ocswitch back to path. Existing files are normalized to plain
|
||||
// JSON and only the provider.ocswitch subtree is patched so unrelated key order stays
|
||||
// stable. New files are still written from the full Raw object. Writes are
|
||||
// atomic.
|
||||
func Save(path string, raw Raw) error {
|
||||
@ -102,7 +107,7 @@ func renderSaveData(path string, raw Raw) ([]byte, error) {
|
||||
if len(bytes.TrimSpace(original)) == 0 {
|
||||
return marshalRaw(raw)
|
||||
}
|
||||
patched, err := patchProviderOLPXDocument(original, raw)
|
||||
patched, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch %s: %w", path, err)
|
||||
}
|
||||
@ -122,8 +127,8 @@ func marshalRaw(raw Raw) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
olpxRaw, ok := olpxProviderValue(raw)
|
||||
func patchProviderDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
providerValue, ok := syncedProviderValue(raw)
|
||||
if !ok {
|
||||
return marshalRaw(raw)
|
||||
}
|
||||
@ -144,7 +149,7 @@ func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
}
|
||||
provider := root.findMember("provider")
|
||||
if provider == nil {
|
||||
return insertObjectMember(normalized, root, "provider", map[string]any{"olpx": olpxRaw})
|
||||
return insertObjectMember(normalized, root, "provider", map[string]any{ProviderKey: providerValue})
|
||||
}
|
||||
if normalized[provider.valueStart] != '{' {
|
||||
return nil, fmt.Errorf("top-level provider must be an object")
|
||||
@ -153,23 +158,23 @@ func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
olpx := providerObj.findMember("olpx")
|
||||
if olpx == nil {
|
||||
return insertObjectMember(normalized, providerObj, "olpx", olpxRaw)
|
||||
providerEntry := providerObj.findMember(ProviderKey)
|
||||
if providerEntry == nil {
|
||||
return insertObjectMember(normalized, providerObj, ProviderKey, providerValue)
|
||||
}
|
||||
return replaceObjectMember(normalized, *olpx, olpxRaw)
|
||||
return replaceObjectMember(normalized, *providerEntry, providerValue)
|
||||
}
|
||||
|
||||
func olpxProviderValue(raw Raw) (map[string]any, bool) {
|
||||
func syncedProviderValue(raw Raw) (map[string]any, bool) {
|
||||
providerRaw, _ := raw["provider"].(map[string]any)
|
||||
if providerRaw == nil {
|
||||
return nil, false
|
||||
}
|
||||
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
|
||||
if olpxRaw == nil {
|
||||
providerEntry, _ := providerRaw[ProviderKey].(map[string]any)
|
||||
if providerEntry == nil {
|
||||
return nil, false
|
||||
}
|
||||
return olpxRaw, true
|
||||
return providerEntry, true
|
||||
}
|
||||
|
||||
type objectSpan struct {
|
||||
@ -410,13 +415,13 @@ func lineIndent(data []byte, pos int) string {
|
||||
return string(data[lineStart:lineEnd])
|
||||
}
|
||||
|
||||
// EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given
|
||||
// local base URL, local api key and alias set. Existing keys on provider.olpx
|
||||
// EnsureOcswitchProvider updates (or creates) the provider.ocswitch entry with the given
|
||||
// local base URL, local api key and alias set. Existing keys on provider.ocswitch
|
||||
// are preserved unless they conflict with the sync intent. For model entries,
|
||||
// sync owns only the alias set: same-name model objects are left untouched so
|
||||
// OpenCode-only metadata survives round-trips. Returns true if the file would
|
||||
// actually change.
|
||||
func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
|
||||
func EnsureOcswitchProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
|
||||
changed := false
|
||||
if _, ok := raw["$schema"]; !ok {
|
||||
raw["$schema"] = "https://opencode.ai/config.json"
|
||||
@ -428,22 +433,22 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
|
||||
raw["provider"] = provRaw
|
||||
changed = true
|
||||
}
|
||||
olpxRaw, _ := provRaw["olpx"].(map[string]any)
|
||||
if olpxRaw == nil {
|
||||
olpxRaw = map[string]any{}
|
||||
provRaw["olpx"] = olpxRaw
|
||||
providerEntry, _ := provRaw[ProviderKey].(map[string]any)
|
||||
if providerEntry == nil {
|
||||
providerEntry = map[string]any{}
|
||||
provRaw[ProviderKey] = providerEntry
|
||||
changed = true
|
||||
}
|
||||
if setIfDiff(olpxRaw, "npm", "@ai-sdk/openai") {
|
||||
if setIfDiff(providerEntry, "npm", "@ai-sdk/openai") {
|
||||
changed = true
|
||||
}
|
||||
if setIfDiff(olpxRaw, "name", "OpenCode LocalProxy CLI") {
|
||||
if setIfDiff(providerEntry, "name", ProviderName) {
|
||||
changed = true
|
||||
}
|
||||
opts, _ := olpxRaw["options"].(map[string]any)
|
||||
opts, _ := providerEntry["options"].(map[string]any)
|
||||
if opts == nil {
|
||||
opts = map[string]any{}
|
||||
olpxRaw["options"] = opts
|
||||
providerEntry["options"] = opts
|
||||
changed = true
|
||||
}
|
||||
if setIfDiff(opts, "baseURL", baseURL) {
|
||||
@ -457,7 +462,7 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
|
||||
}
|
||||
// Build models map from alias list. Preserve any existing per-model objects
|
||||
// verbatim if the alias key matches; drop aliases removed locally.
|
||||
existingModels, _ := olpxRaw["models"].(map[string]any)
|
||||
existingModels, _ := providerEntry["models"].(map[string]any)
|
||||
newModels := map[string]any{}
|
||||
aliasSet := map[string]bool{}
|
||||
for _, a := range aliases {
|
||||
@ -476,43 +481,43 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool
|
||||
}
|
||||
}
|
||||
if !mapsEqualShallow(existingModels, newModels) {
|
||||
olpxRaw["models"] = newModels
|
||||
providerEntry["models"] = newModels
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// ValidateOLPXProvider checks that provider.olpx matches the MVP sync contract.
|
||||
func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
|
||||
// ValidateOcswitchProvider checks that provider.ocswitch matches the MVP sync contract.
|
||||
func ValidateOcswitchProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
|
||||
provRaw, _ := raw["provider"].(map[string]any)
|
||||
if provRaw == nil {
|
||||
return fmt.Errorf("missing provider object")
|
||||
}
|
||||
olpxRaw, _ := provRaw["olpx"].(map[string]any)
|
||||
if olpxRaw == nil {
|
||||
return fmt.Errorf("missing provider.olpx")
|
||||
providerEntry, _ := provRaw[ProviderKey].(map[string]any)
|
||||
if providerEntry == nil {
|
||||
return fmt.Errorf("missing provider.%s", ProviderKey)
|
||||
}
|
||||
if npm, _ := olpxRaw["npm"].(string); npm != "@ai-sdk/openai" {
|
||||
return fmt.Errorf("provider.olpx.npm must be @ai-sdk/openai")
|
||||
if npm, _ := providerEntry["npm"].(string); npm != "@ai-sdk/openai" {
|
||||
return fmt.Errorf("provider.%s.npm must be @ai-sdk/openai", ProviderKey)
|
||||
}
|
||||
if name, _ := olpxRaw["name"].(string); name != "OpenCode LocalProxy CLI" {
|
||||
return fmt.Errorf("provider.olpx.name must be OpenCode LocalProxy CLI")
|
||||
if name, _ := providerEntry["name"].(string); name != ProviderName {
|
||||
return fmt.Errorf("provider.%s.name must be %s", ProviderKey, ProviderName)
|
||||
}
|
||||
opts, _ := olpxRaw["options"].(map[string]any)
|
||||
opts, _ := providerEntry["options"].(map[string]any)
|
||||
if opts == nil {
|
||||
return fmt.Errorf("provider.olpx.options missing")
|
||||
return fmt.Errorf("provider.%s.options missing", ProviderKey)
|
||||
}
|
||||
if got, _ := opts["baseURL"].(string); got != baseURL {
|
||||
return fmt.Errorf("provider.olpx.options.baseURL mismatch")
|
||||
return fmt.Errorf("provider.%s.options.baseURL mismatch", ProviderKey)
|
||||
}
|
||||
if got, _ := opts["apiKey"].(string); got != apiKey {
|
||||
return fmt.Errorf("provider.olpx.options.apiKey mismatch")
|
||||
return fmt.Errorf("provider.%s.options.apiKey mismatch", ProviderKey)
|
||||
}
|
||||
if got, ok := opts["setCacheKey"].(bool); !ok || !got {
|
||||
return fmt.Errorf("provider.olpx.options.setCacheKey must be true")
|
||||
return fmt.Errorf("provider.%s.options.setCacheKey must be true", ProviderKey)
|
||||
}
|
||||
models, _ := olpxRaw["models"].(map[string]any)
|
||||
models, _ := providerEntry["models"].(map[string]any)
|
||||
if models == nil {
|
||||
return fmt.Errorf("provider.olpx.models missing")
|
||||
return fmt.Errorf("provider.%s.models missing", ProviderKey)
|
||||
}
|
||||
expected := append([]string(nil), aliases...)
|
||||
sort.Strings(expected)
|
||||
@ -520,17 +525,17 @@ func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) err
|
||||
for alias, v := range models {
|
||||
modelCfg, _ := v.(map[string]any)
|
||||
if modelCfg == nil {
|
||||
return fmt.Errorf("provider.olpx.models.%s must be an object", alias)
|
||||
return fmt.Errorf("provider.%s.models.%s must be an object", ProviderKey, alias)
|
||||
}
|
||||
actual = append(actual, alias)
|
||||
}
|
||||
sort.Strings(actual)
|
||||
if len(actual) != len(expected) {
|
||||
return fmt.Errorf("provider.olpx.models alias set mismatch")
|
||||
return fmt.Errorf("provider.%s.models alias set mismatch", ProviderKey)
|
||||
}
|
||||
for i := range actual {
|
||||
if actual[i] != expected[i] {
|
||||
return fmt.Errorf("provider.olpx.models alias set mismatch")
|
||||
return fmt.Errorf("provider.%s.models alias set mismatch", ProviderKey)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@ -556,13 +561,13 @@ type ImportableProvider struct {
|
||||
}
|
||||
|
||||
// ImportCustomProviders scans raw for @ai-sdk/openai custom providers that
|
||||
// declare baseURL and an apiKey-compatible setting. The `olpx` id itself is
|
||||
// declare baseURL and an apiKey-compatible setting. The synced provider id itself is
|
||||
// skipped so sync output is not re-imported.
|
||||
func ImportCustomProviders(raw Raw) []ImportableProvider {
|
||||
out := []ImportableProvider{}
|
||||
provRaw, _ := raw["provider"].(map[string]any)
|
||||
for id, v := range provRaw {
|
||||
if id == "olpx" {
|
||||
if id == ProviderKey {
|
||||
continue
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
|
||||
@ -64,35 +64,35 @@ func TestResolveGlobalConfigPathPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOLPXProvider(t *testing.T) {
|
||||
func TestValidateOcswitchProvider(t *testing.T) {
|
||||
raw := Raw{}
|
||||
aliases := []string{"gpt-5.4", "gpt-5.4-mini"}
|
||||
baseURL := "http://127.0.0.1:9982/v1"
|
||||
apiKey := "olpx-local"
|
||||
EnsureOLPXProvider(raw, baseURL, apiKey, aliases)
|
||||
apiKey := "ocswitch-local"
|
||||
EnsureOcswitchProvider(raw, baseURL, apiKey, aliases)
|
||||
|
||||
providerRaw, _ := raw["provider"].(map[string]any)
|
||||
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
|
||||
opts, _ := olpxRaw["options"].(map[string]any)
|
||||
providerEntry, _ := providerRaw[ProviderKey].(map[string]any)
|
||||
opts, _ := providerEntry["options"].(map[string]any)
|
||||
if got, ok := opts["setCacheKey"].(bool); !ok || !got {
|
||||
t.Fatalf("provider.olpx.options.setCacheKey = %#v, want true", opts["setCacheKey"])
|
||||
t.Fatalf("provider.%s.options.setCacheKey = %#v, want true", ProviderKey, opts["setCacheKey"])
|
||||
}
|
||||
|
||||
if err := ValidateOLPXProvider(raw, baseURL, apiKey, aliases); err != nil {
|
||||
t.Fatalf("ValidateOLPXProvider() unexpected error: %v", err)
|
||||
if err := ValidateOcswitchProvider(raw, baseURL, apiKey, aliases); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) {
|
||||
func TestEnsureOcswitchProviderPreservesExistingModelMetadata(t *testing.T) {
|
||||
raw := Raw{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{
|
||||
@ -116,14 +116,14 @@ func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
|
||||
changed := EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
|
||||
if changed {
|
||||
t.Fatal("EnsureOLPXProvider() reported change for unchanged same-name alias")
|
||||
t.Fatal("EnsureOcswitchProvider() reported change for unchanged same-name alias")
|
||||
}
|
||||
|
||||
providerRaw := raw["provider"].(map[string]any)
|
||||
olpxRaw := providerRaw["olpx"].(map[string]any)
|
||||
models := olpxRaw["models"].(map[string]any)
|
||||
providerEntry := providerRaw[ProviderKey].(map[string]any)
|
||||
models := providerEntry["models"].(map[string]any)
|
||||
model := models["gpt-5.4"].(map[string]any)
|
||||
if got := model["name"]; got != "custom-display-name" {
|
||||
t.Fatalf("model name = %#v, want custom-display-name preserved", got)
|
||||
@ -142,16 +142,16 @@ func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOLPXProviderDoesNotPanicOnComparableMetadata(t *testing.T) {
|
||||
func TestEnsureOcswitchProviderDoesNotPanicOnComparableMetadata(t *testing.T) {
|
||||
raw := Raw{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{
|
||||
@ -169,25 +169,25 @@ func TestEnsureOLPXProviderDoesNotPanicOnComparableMetadata(t *testing.T) {
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("EnsureOLPXProvider() panicked with slice metadata: %v", r)
|
||||
t.Fatalf("EnsureOcswitchProvider() panicked with slice metadata: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
|
||||
changed := EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
|
||||
if changed {
|
||||
t.Fatal("EnsureOLPXProvider() reported change for unchanged alias metadata with slices")
|
||||
t.Fatal("EnsureOcswitchProvider() reported change for unchanged alias metadata with slices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOLPXProviderAllowsCustomModelMetadata(t *testing.T) {
|
||||
func TestValidateOcswitchProviderAllowsCustomModelMetadata(t *testing.T) {
|
||||
raw := Raw{
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{
|
||||
@ -200,62 +200,62 @@ func TestValidateOLPXProviderAllowsCustomModelMetadata(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := ValidateOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOLPXProvider() unexpected error for custom metadata: %v", err)
|
||||
if err := ValidateOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider() unexpected error for custom metadata: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataReplacesExistingProviderOLPXOnly(t *testing.T) {
|
||||
func TestRenderSaveDataReplacesExistingProviderOnly(t *testing.T) {
|
||||
raw := Raw{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "olpx/gpt-5.4",
|
||||
"model": "ocswitch/gpt-5.4",
|
||||
"provider": map[string]any{
|
||||
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
|
||||
},
|
||||
"openai": map[string]any{"npm": "@ai-sdk/openai"},
|
||||
},
|
||||
"small_model": "olpx/gpt-5.4-mini",
|
||||
"small_model": "ocswitch/gpt-5.4-mini",
|
||||
}
|
||||
original := []byte("{\n \"model\": \"olpx/old\",\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"olpx\": {\n \"npm\": \"old\",\n \"options\": {\"baseURL\": \"http://old/v1\"},\n \"models\": {\"old\": {\"name\": \"old\"}}\n },\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"small_model\": \"olpx/old-mini\"\n}\n")
|
||||
original := []byte("{\n \"model\": \"ocswitch/old\",\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"ocswitch\": {\n \"npm\": \"old\",\n \"options\": {\"baseURL\": \"http://old/v1\"},\n \"models\": {\"old\": {\"name\": \"old\"}}\n },\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"small_model\": \"ocswitch/old-mini\"\n}\n")
|
||||
|
||||
got, err := patchProviderOLPXDocument(original, raw)
|
||||
got, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
assertStringOrder(t, string(got), []string{`"model"`, `"provider"`, `"small_model"`})
|
||||
assertStringOrder(t, string(got), []string{`"anthropic"`, `"olpx"`, `"openai"`})
|
||||
assertStringOrder(t, string(got), []string{`"anthropic"`, `"ocswitch"`, `"openai"`})
|
||||
if strings.Contains(string(got), `"npm": "old"`) {
|
||||
t.Fatalf("old provider.olpx content still present: %s", string(got))
|
||||
t.Fatalf("old provider.ocswitch content still present: %s", string(got))
|
||||
}
|
||||
var saved Raw
|
||||
if err := json.Unmarshal(got, &saved); err != nil {
|
||||
t.Fatalf("unmarshal patched json: %v", err)
|
||||
}
|
||||
if err := ValidateOLPXProvider(saved, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOLPXProvider(saved) error: %v", err)
|
||||
if err := ValidateOcswitchProvider(saved, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider(saved) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(t *testing.T) {
|
||||
func TestRenderSaveDataInsertsOcswitchWithoutReorderingProviderKeys(t *testing.T) {
|
||||
raw := Raw{
|
||||
"provider": map[string]any{
|
||||
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
|
||||
@ -263,38 +263,38 @@ func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(t *testing.T) {
|
||||
"openai": map[string]any{"npm": "@ai-sdk/openai"},
|
||||
},
|
||||
}
|
||||
original := []byte("{\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"model\": \"olpx/gpt-5.4\"\n}\n")
|
||||
original := []byte("{\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"model\": \"ocswitch/gpt-5.4\"\n}\n")
|
||||
|
||||
got, err := patchProviderOLPXDocument(original, raw)
|
||||
got, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"olpx"`})
|
||||
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"ocswitch"`})
|
||||
}
|
||||
|
||||
func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) {
|
||||
raw := Raw{
|
||||
"model": "olpx/gpt-5.4",
|
||||
"model": "ocswitch/gpt-5.4",
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
|
||||
},
|
||||
},
|
||||
"small_model": "olpx/gpt-5.4-mini",
|
||||
"small_model": "ocswitch/gpt-5.4-mini",
|
||||
}
|
||||
original := []byte("{\n \"model\": \"olpx/gpt-5.4\",\n \"small_model\": \"olpx/gpt-5.4-mini\"\n}\n")
|
||||
original := []byte("{\n \"model\": \"ocswitch/gpt-5.4\",\n \"small_model\": \"ocswitch/gpt-5.4-mini\"\n}\n")
|
||||
|
||||
got, err := patchProviderOLPXDocument(original, raw)
|
||||
got, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`})
|
||||
@ -303,12 +303,12 @@ func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) {
|
||||
func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
|
||||
raw := Raw{
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
|
||||
@ -317,9 +317,9 @@ func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
|
||||
}
|
||||
original := []byte("{\n // comment\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"},\n },\n}\n")
|
||||
|
||||
got, err := patchProviderOLPXDocument(original, raw)
|
||||
got, err := patchProviderDocument(original, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||
t.Fatalf("patchProviderDocument() error: %v", err)
|
||||
}
|
||||
assertValidJSON(t, got)
|
||||
if bytes.Contains(got, []byte("// comment")) {
|
||||
@ -330,51 +330,51 @@ func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
|
||||
func TestRenderSaveDataRejectsInvalidJSONC(t *testing.T) {
|
||||
raw := Raw{
|
||||
"provider": map[string]any{
|
||||
"olpx": map[string]any{
|
||||
ProviderKey: map[string]any{
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "OpenCode LocalProxy CLI",
|
||||
"name": ProviderName,
|
||||
"options": map[string]any{
|
||||
"baseURL": "http://127.0.0.1:9982/v1",
|
||||
"apiKey": "olpx-local",
|
||||
"apiKey": "ocswitch-local",
|
||||
"setCacheKey": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := patchProviderOLPXDocument([]byte(`{"provider": {`), raw); err == nil {
|
||||
if _, err := patchProviderDocument([]byte(`{"provider": {`), raw); err == nil {
|
||||
t.Fatal("expected invalid json/jsonc error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) {
|
||||
raw := Raw{}
|
||||
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
|
||||
EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
|
||||
|
||||
if _, err := patchProviderOLPXDocument([]byte(`{"provider":"bad"}`), raw); err == nil {
|
||||
if _, err := patchProviderDocument([]byte(`{"provider":"bad"}`), raw); err == nil {
|
||||
t.Fatal("expected provider object error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) {
|
||||
raw := Raw{}
|
||||
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
|
||||
EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
|
||||
|
||||
if _, err := patchProviderOLPXDocument([]byte(`[]`), raw); err == nil {
|
||||
if _, err := patchProviderDocument([]byte(`[]`), raw); err == nil {
|
||||
t.Fatal("expected top-level object error")
|
||||
}
|
||||
if _, err := patchProviderOLPXDocument([]byte("{} trailing"), raw); err == nil {
|
||||
if _, err := patchProviderDocument([]byte(`{} trailing`), raw); err == nil {
|
||||
t.Fatal("expected single top-level object error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "opencode.jsonc")
|
||||
if err := os.WriteFile(path, []byte("{\n \"model\": \"olpx/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte("{\n \"model\": \"ocswitch/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write seed config: %v", err)
|
||||
}
|
||||
raw := Raw{}
|
||||
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
|
||||
EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
|
||||
|
||||
if err := Save(path, raw); err != nil {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
@ -388,14 +388,14 @@ func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load(saved) error: %v", err)
|
||||
}
|
||||
if err := ValidateOLPXProvider(loaded, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOLPXProvider(loaded) error: %v", err)
|
||||
if err := ValidateOcswitchProvider(loaded, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}); err != nil {
|
||||
t.Fatalf("ValidateOcswitchProvider(loaded) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "opencode.jsonc")
|
||||
seed := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"provider\": {\n \"olpx\": {\n \"npm\": \"@ai-sdk/openai\",\n \"name\": \"OpenCode LocalProxy CLI\",\n \"options\": {\n \"baseURL\": \"http://127.0.0.1:9982/v1\",\n \"apiKey\": \"olpx-local\",\n \"setCacheKey\": true\n },\n \"models\": {\n \"gpt-5.4\": {\n \"name\": \"custom-display-name\",\n \"limit\": {\n \"context\": 272000,\n \"output\": 128000\n },\n \"options\": {\n \"serviceTier\": \"priority\"\n }\n }\n }\n }\n }\n}\n")
|
||||
seed := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"provider\": {\n \"ocswitch\": {\n \"npm\": \"@ai-sdk/openai\",\n \"name\": \"OpenCode Provider Switch CLI\",\n \"options\": {\n \"baseURL\": \"http://127.0.0.1:9982/v1\",\n \"apiKey\": \"ocswitch-local\",\n \"setCacheKey\": true\n },\n \"models\": {\n \"gpt-5.4\": {\n \"name\": \"custom-display-name\",\n \"limit\": {\n \"context\": 272000,\n \"output\": 128000\n },\n \"options\": {\n \"serviceTier\": \"priority\"\n }\n }\n }\n }\n }\n}\n")
|
||||
if err := os.WriteFile(path, seed, 0o600); err != nil {
|
||||
t.Fatalf("write seed config: %v", err)
|
||||
}
|
||||
@ -404,9 +404,9 @@ func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load(seed) error: %v", err)
|
||||
}
|
||||
changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
|
||||
changed := EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"})
|
||||
if changed {
|
||||
t.Fatal("EnsureOLPXProvider() reported change for preserved same-name alias metadata")
|
||||
t.Fatal("EnsureOcswitchProvider() reported change for preserved same-name alias metadata")
|
||||
}
|
||||
if err := Save(path, raw); err != nil {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
@ -417,8 +417,8 @@ func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) {
|
||||
t.Fatalf("Load(saved) error: %v", err)
|
||||
}
|
||||
providerRaw := loaded["provider"].(map[string]any)
|
||||
olpxRaw := providerRaw["olpx"].(map[string]any)
|
||||
models := olpxRaw["models"].(map[string]any)
|
||||
providerEntry := providerRaw[ProviderKey].(map[string]any)
|
||||
models := providerEntry["models"].(map[string]any)
|
||||
model := models["gpt-5.4"].(map[string]any)
|
||||
if got := model["name"]; got != "custom-display-name" {
|
||||
t.Fatalf("saved model name = %#v, want custom-display-name preserved", got)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// 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.
|
||||
package proxy
|
||||
|
||||
@ -17,7 +17,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
var firstByteTimeout = 15 * time.Second
|
||||
@ -32,7 +32,7 @@ type openAIError struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Server is the local olpx HTTP proxy.
|
||||
// Server is the local ocswitch HTTP proxy.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
@ -62,7 +62,7 @@ func New(cfg *config.Config) *Server {
|
||||
Transport: transport,
|
||||
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{
|
||||
"id": aliasName,
|
||||
"object": "model",
|
||||
"owned_by": "olpx",
|
||||
"owned_by": config.AppName,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -371,7 +371,7 @@ func errorTypeForStatus(status int) string {
|
||||
}
|
||||
|
||||
func normalizeAliasName(model string) string {
|
||||
const prefix = "olpx/"
|
||||
prefix := config.AppName + "/"
|
||||
if strings.HasPrefix(model, prefix) {
|
||||
trimmed := strings.TrimPrefix(model, prefix)
|
||||
if trimmed != "" {
|
||||
@ -381,14 +381,14 @@ func normalizeAliasName(model string) string {
|
||||
return model
|
||||
}
|
||||
|
||||
// writeDebugHeaders sets the X-OLPX-* debug headers before WriteHeader.
|
||||
// writeDebugHeaders sets the X-OCSWITCH-* debug headers before WriteHeader.
|
||||
func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) {
|
||||
h := w.Header()
|
||||
h.Set("X-OLPX-Alias", alias)
|
||||
h.Set("X-OLPX-Provider", provider)
|
||||
h.Set("X-OLPX-Remote-Model", remoteModel)
|
||||
h.Set("X-OLPX-Attempt", fmt.Sprintf("%d", attempt))
|
||||
h.Set("X-OLPX-Failover-Count", fmt.Sprintf("%d", failoverCount))
|
||||
h.Set("X-OCSWITCH-Alias", alias)
|
||||
h.Set("X-OCSWITCH-Provider", provider)
|
||||
h.Set("X-OCSWITCH-Remote-Model", remoteModel)
|
||||
h.Set("X-OCSWITCH-Attempt", fmt.Sprintf("%d", attempt))
|
||||
h.Set("X-OCSWITCH-Failover-Count", fmt.Sprintf("%d", failoverCount))
|
||||
}
|
||||
|
||||
// hopByHopHeaders lists headers that must not be forwarded per RFC 7230.
|
||||
|
||||
@ -11,17 +11,17 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -64,7 +64,7 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
defer second.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"},
|
||||
{ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"},
|
||||
@ -76,8 +76,8 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"olpx/gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"ocswitch/gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
@ -96,14 +96,14 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
if secondSeenModel != "up-2" {
|
||||
t.Fatalf("second upstream model = %q, want up-2", secondSeenModel)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" {
|
||||
t.Fatalf("X-OLPX-Attempt = %q, want 2", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "2" {
|
||||
t.Fatalf("X-OCSWITCH-Attempt = %q, want 2", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" {
|
||||
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "1" {
|
||||
t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 1", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,7 +124,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||
defer second.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: first.URL + "/v1"},
|
||||
{ID: "p2", BaseURL: second.URL + "/v1"},
|
||||
@ -137,7 +137,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -149,8 +149,8 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||
if calledSecond {
|
||||
t.Fatal("second upstream should not be called for 400 response")
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" {
|
||||
t.Fatalf("X-OLPX-Provider = %q, want p1", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p1" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p1", got)
|
||||
}
|
||||
if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` {
|
||||
t.Fatalf("body = %q", body)
|
||||
@ -181,7 +181,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
|
||||
defer enabled.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true},
|
||||
{ID: "p2", BaseURL: enabled.URL + "/v1"},
|
||||
@ -194,7 +194,7 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
@ -210,14 +210,14 @@ func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
|
||||
if seenModel != "up-2" {
|
||||
t.Fatalf("enabled upstream model = %q, want up-2", seenModel)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Attempt"); got != "1" {
|
||||
t.Fatalf("X-OLPX-Attempt = %q, want 1", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "1" {
|
||||
t.Fatalf("X-OCSWITCH-Attempt = %q, want 1", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "0" {
|
||||
t.Fatalf("X-OLPX-Failover-Count = %q, want 0", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Failover-Count"); got != "0" {
|
||||
t.Fatalf("X-OCSWITCH-Failover-Count = %q, want 0", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
||||
}
|
||||
if body := rr.Body.String(); body != "data: ok\n\n" {
|
||||
t.Fatalf("body = %q, want SSE payload", body)
|
||||
@ -228,7 +228,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: "olpx-local"},
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: "https://p1.example.com/v1"},
|
||||
{ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true},
|
||||
@ -241,7 +241,7 @@ func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
srv.handleModels(rr, req)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user