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