refactor: rename ops CLI to olpx

Align the binary, config, provider, proxy, tests, and docs with the OpenCode LocalProxy CLI rename so user-facing guidance and runtime identifiers stay consistent.
This commit is contained in:
apale7 2026-04-17 11:23:15 +08:00
parent 37c6ee0640
commit 7c30e9d5eb
18 changed files with 1091 additions and 418 deletions

View File

@ -0,0 +1,607 @@
# OLPX Agent-First CLI Help System
## Summary
`olpx` already has a working CLI and a reasonably complete README, but its help
surface is still optimized for a human reading source-adjacent docs, not for an
AI agent trying to configure the tool end-to-end with high reliability.
This PRD defines a narrow product change:
- every user-facing `olpx` command must provide `Long` help
- every user-facing `olpx` command must provide `Example` help
- help content must be written for **AI agent execution reliability first**
- manual CLI ergonomics remain important, but are secondary to agent clarity
The intended result is that an AI agent can use `olpx --help` and nested
`--help` output as a trustworthy local interface contract for discovery,
planning, execution, and recovery during configuration.
## Product Goal
Make `olpx` self-describing enough that a capable AI agent can complete normal
configuration workflows without relying on hidden tribal knowledge, source code
inspection, or README-only instructions.
## Core User Need
User wants to say, in effect:
"Configure `olpx` for my providers and aliases, then sync OpenCode and tell me
how to run it."
The agent should be able to do that safely by reading CLI help output and
executing commands in order.
## Why This Matters
Current repository state already supports the core workflow:
- add or import upstream providers
- create aliases and bind ordered targets
- validate config via `olpx doctor`
- sync aliases into OpenCode via `olpx opencode sync`
- run proxy via `olpx serve`
But the command tree does not yet expose the full operational contract through
help text. In practice this means:
- some commands only have `Short`
- side effects are not always explained in help output
- default target paths and scope boundaries are not always repeated where used
- the "what to do next" step is often only in README, not in command help
- an agent may need to infer workflow rules from source code instead of help
This is acceptable for an engineering MVP, but not good enough if `olpx` wants
to recommend agent-driven configuration as a first-class path.
## Design Principle
Treat CLI help as a **machine-consumable operational interface**, not as a
minimal hint for humans.
That means help text should answer these questions explicitly for each command:
1. What job does this command do?
2. When should an agent call it?
3. What must already exist before calling it?
4. What state does it read or write?
5. What does it intentionally not do?
6. What command usually comes next?
7. What concrete examples should be copied for common workflows?
## Priority Order
When there is a tradeoff, the implementation should optimize for:
1. AI agent understanding and execution reliability
2. Deterministic and explicit side-effect descriptions
3. Copy-paste-safe examples
4. Human readability
5. Brevity
This means slightly longer help is acceptable if it reduces agent mistakes.
## Non-Goals
1. No GUI or local web setup flow in this task.
2. No new interactive wizard in this task.
3. No major command model redesign unless required to support help clarity.
4. No attempt to encode every README detail into root help output.
5. No hidden behavior added only for agents; behavior must stay consistent with
the real command implementation.
## Scope
### In Scope
All user-facing commands in the current Cobra tree:
- `olpx`
- `olpx serve`
- `olpx doctor`
- `olpx provider`
- `olpx provider add`
- `olpx provider list`
- `olpx provider remove`
- `olpx provider import-opencode`
- `olpx alias`
- `olpx alias add`
- `olpx alias list`
- `olpx alias bind`
- `olpx alias unbind`
- `olpx alias remove`
- `olpx opencode`
- `olpx opencode sync`
### Also In Scope
- defining a consistent writing contract for `Long` and `Example`
- deciding which workflow facts must be repeated in help instead of delegated to
README only
- optional README adjustments if needed to point agents toward `--help` as the
authoritative interface contract
### Out of Scope
- adding brand-new command groups just for documentation cosmetics
- changing proxy/runtime semantics unrelated to help clarity
- solving remote connectivity testing beyond what already exists
## Agent-First Help Requirements
Every command help page must be written to support agent execution. The text
does not need to be machine-parseable JSON, but it must be operationally
unambiguous.
### Requirement 1: Each command must define `Long`
`Long` must not repeat `Short` mechanically. It must explain behavior,
preconditions, scope, and next-step guidance.
Minimum `Long` contents per command:
1. command purpose
2. state read/write behavior
3. prerequisites or expected prior commands
4. important defaults and path resolution rules
5. behavior boundaries or non-effects
6. recommended next step
### Requirement 2: Each command must define `Example`
`Example` must contain realistic command lines that an agent can copy with minor
substitutions.
Examples must prefer:
- real flag names
- complete command lines
- stable placeholder values
- workflow-oriented ordering
- one example per important usage pattern
Examples must avoid:
- vague ellipses when a concrete placeholder is better
- examples that omit required flags without explanation
- examples that imply unsupported behavior
### Requirement 3: Root and group commands must explain workflow position
Commands like `olpx`, `olpx provider`, `olpx alias`, and `olpx opencode` are not
action commands themselves, but they are decision points for an agent.
Their help must answer:
- what subcommands exist
- in what order agents usually use them
- which subcommand to inspect next for each workflow
### Requirement 4: Help must surface side effects explicitly
Whenever a command writes local config or OpenCode config, help must say so.
Examples:
- provider commands write `olpx` config
- alias commands write `olpx` config
- `opencode sync` writes the target OpenCode config unless `--dry-run`
- `doctor` validates statically and does not call upstream providers
- `serve` starts a long-running local proxy and does not mutate config
### Requirement 5: Help must surface defaults locally
Agents should not need to jump to README or source to know command-local
defaults.
Examples of defaults that must appear where relevant:
- default `olpx` config path via `--config`
- default OpenCode sync target resolution order
- default proxy bind address and API key when describing `serve` or workflows
### Requirement 6: Help must be safe for secrets
Help examples may use placeholder keys like `sk-example`, but must never suggest
printing or leaking real secrets. Examples should not instruct agents to copy
config files to chat or expose API keys in logs.
## Content Contract By Command Type
### Root Command: `olpx`
`Long` should define the full happy-path workflow:
1. add/import providers
2. create alias and bind targets
3. run `doctor`
4. run `opencode sync`
5. run `serve`
`Example` should include:
- inspect root help
- scratch setup flow
- import-first flow
### Group Command: `provider`
`Long` should explain that providers are upstream OpenAI-compatible endpoints
used by alias targets. It should state that provider definitions live in local
`olpx` config and are separate from alias routing.
`Example` should include:
- add provider
- import from OpenCode
- list providers
### Action Command: `provider add`
`Long` should explain:
- creates or updates a provider entry
- `--base-url` must include `/v1`
- omitted mutable fields may preserve existing values on update
- repeated `--header` appends explicit extra headers
- command does not validate upstream reachability
`Example` should include:
- minimal add
- add with API key
- add with repeated headers
- update only base URL of existing provider
### Action Command: `provider list`
`Long` should explain that output is for inspection, redacts keys, and is often
used by agents to confirm imported or saved provider IDs before binding aliases.
`Example` should include:
- plain listing
- listing with explicit `--config`
### Action Command: `provider remove`
`Long` should explain:
- removes provider from `olpx` config
- does not automatically clean alias references
- follow-up `doctor` may fail if aliases still reference removed provider
`Example` should include:
- remove provider
- inspect aliases afterward
### Action Command: `provider import-opencode`
`Long` should explain:
- source defaults to global OpenCode config resolution
- only supported import shape is config-defined `@ai-sdk/openai` custom
providers with `baseURL` and `apiKey`
- unsupported providers are skipped by design
- `--overwrite` changes update semantics
`Example` should include:
- default import
- import from explicit file
- overwrite existing providers
### Group Command: `alias`
`Long` should explain aliases as the primary user-facing abstraction exposed to
OpenCode as `olpx/<alias>`, and that target order defines failover priority.
`Example` should include:
- create alias
- bind primary and fallback targets
- list aliases
### Action Command: `alias add`
`Long` should explain:
- creates or updates alias metadata only
- does not add targets
- disabled aliases are not meant for OpenCode exposure until enabled and valid
`Example` should include:
- create enabled alias
- create disabled alias
- update display name
### Action Command: `alias list`
`Long` should explain output semantics:
- alias enabled/disabled state
- target ordering
- target enabled markers
- common use before `doctor` and `opencode sync`
`Example` should include:
- plain listing
- listing under explicit config path
### Action Command: `alias bind`
`Long` should explain:
- appends target in failover order
- provider must already exist
- alias auto-creates if missing
- binding does not test upstream health
- order matters operationally
`Example` should include:
- first target bind
- second fallback bind
- bind disabled target
### Action Command: `alias unbind`
`Long` should explain:
- removes one concrete target tuple from alias
- does not delete the alias itself
- may leave alias invalid if no enabled targets remain
`Example` should include:
- remove one fallback target
- run `doctor` afterward
### Action Command: `alias remove`
`Long` should explain:
- removes entire alias from local config
- future `opencode sync` will stop exposing it in `provider.olpx.models`
- does not directly remove model selection already set elsewhere in OpenCode
`Example` should include:
- remove alias
- sync afterward
### Action Command: `doctor`
`Long` should explain:
- performs static validation only
- validates local config structure and OpenCode sync preview assumptions
- does not issue real upstream requests
- should be called before `opencode sync` or `serve`
`Example` should include:
- plain validation
- validation with alternate config path
### Group Command: `opencode`
`Long` should explain that these commands manage the narrow integration boundary
between `olpx` and OpenCode, and do not attempt full OpenCode config takeover.
`Example` should include:
- inspect sync help
- run sync dry-run
### Action Command: `opencode sync`
Existing `Long` is a strong starting point but must be aligned to the new help
contract.
`Long` should explain:
- exact write target rules
- what fields are mutated and not mutated
- that aliases become `provider.olpx.models`
- meaning of `--dry-run`
- recommended call order around `doctor`
`Example` should include:
- basic sync
- dry-run preview
- sync and set top-level model
- sync and set model plus small model
- sync to explicit target file
### Action Command: `serve`
`Long` should explain:
- starts long-running proxy
- reads validated local config
- requires a valid alias/provider setup first
- handles OpenCode traffic at the configured local base URL
- should generally be called after `doctor` and `opencode sync`
`Example` should include:
- start with defaults
- start with explicit config path
## Writing Rules For `Long`
All `Long` help should follow a shared style so agents can scan it quickly.
Recommended structure:
1. one-sentence job statement
2. short paragraph describing read/write effects
3. short paragraph describing prerequisites and defaults
4. short paragraph describing boundaries or important caveats
5. one-line next step recommendation when useful
Writing rules:
- use direct, operational language
- prefer exact nouns from the product: provider, alias, target, OpenCode config
- say "does" and "does not" explicitly
- repeat important defaults instead of assuming root help was read
- avoid marketing language
- avoid implementation trivia that does not affect execution
## Writing Rules For `Example`
Examples should be optimized for agent reuse.
Rules:
- use fenced multi-line examples where grouping improves clarity
- order examples from safest/common to more advanced
- keep placeholders stable across commands when possible
- use provider IDs and alias names that match repository terminology
- reflect real workflow order
Preferred placeholder vocabulary:
- provider ids: `su8`, `codex`, `relay`
- alias names: `gpt-5.4`, `gpt-5.4-mini`
- api keys: `sk-example`
- local paths: `/path/to/opencode.jsonc`
## Workflow Scenarios The Help Must Support
Help output across the command tree must enable an agent to execute these common
scenarios without README-only dependency.
### Scenario 1: Configure from scratch
1. inspect `olpx --help`
2. add one or more providers
3. add alias
4. bind targets in order
5. run `doctor`
6. run `opencode sync`
7. run `serve`
### Scenario 2: Import existing OpenCode provider definitions first
1. inspect `olpx provider import-opencode --help`
2. import supported providers
3. list providers
4. create aliases and bindings
5. validate, sync, serve
### Scenario 3: Change only one provider endpoint
1. inspect `provider add --help`
2. update existing provider base URL or key
3. run `doctor`
4. restart `serve` if already running
### Scenario 4: Remove or replace a fallback target safely
1. inspect `alias list`
2. `alias unbind`
3. optionally `provider remove`
4. run `doctor`
5. run `opencode sync` if alias exposure changed
## README Relationship
README remains useful, but after this task its role changes:
- README explains the product and quick-start narrative
- CLI help becomes the authoritative local execution contract
- README may include one short section telling users and agents to prefer
command-local `--help` for exact behavior and flag usage
README should not be the only place where critical command semantics live.
## Acceptance Criteria
### Functional Acceptance
1. Every user-facing Cobra command in the current `olpx` tree defines non-empty
`Long` and non-empty `Example` help.
2. `olpx --help` and all nested `--help` pages expose enough information for an
agent to discover the intended configuration workflow without reading source.
3. Help text for mutating commands explicitly states what files/config are
written.
4. Help text for non-mutating commands explicitly states that they do not write
config or contact upstreams when that distinction matters.
5. `opencode sync --help` explicitly documents default target resolution and the
non-default behavior of `--set-model` and `--set-small-model`.
6. `provider add --help` explicitly documents `/v1` requirement and update
semantics for omitted fields.
7. `alias bind --help` explicitly documents auto-create behavior and ordering
semantics.
### Quality Acceptance
1. Help examples are copy-paste-ready after simple placeholder substitution.
2. Help text does not contain contradictions with actual runtime behavior.
3. Terminology is consistent across all help pages.
4. No command relies only on README for a behavior that materially affects safe
execution.
### Agent Success Acceptance
This task is successful if a fresh agent can reasonably do the following with
CLI help as its primary reference:
1. determine the canonical happy-path setup order
2. configure providers and aliases
3. understand what `doctor` validates and what it does not
4. sync aliases into OpenCode safely
5. understand when to start `serve`
## Implementation Notes
This PRD does not require a new documentation subsystem. Current Cobra support
is enough:
- `Short`
- `Long`
- `Example`
Implementation should prefer a small, consistent helper style only if needed to
avoid repeated wording mistakes. Do not over-abstract help generation unless the
duplication becomes genuinely harmful.
## Risks
1. Help text may drift from behavior if future command semantics change without
updating examples.
2. Longer help output may become noisy if not structured carefully.
3. Over-optimizing for agents could make help feel less natural for humans.
## Risk Mitigation
1. Keep help operational and specific, not verbose by default.
2. Prefer one shared writing contract across all commands.
3. Add tests that assert key phrases exist for high-risk commands.
4. Treat help text as behavior-adjacent surface that must change with command
semantics.
## Future Extensions
These are explicitly out of this task, but compatible with it later:
- `olpx setup` guided workflow command
- shell completion tuned for provider/alias names
- structured `--help-format json` for agent-native consumption
- README agent prompt template that mirrors the help contract
## Final Product Decision
`olpx` should recommend an **agent-first, CLI-native** configuration workflow.
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
of the product interface.

View File

@ -0,0 +1,66 @@
{
"id": "04-17-agent-first-cli-help-prd",
"name": "04-17-agent-first-cli-help-prd",
"title": "PRD: agent-first CLI help system",
"description": "Define an agent-first product spec for olpx CLI Long/Example help and related docs behavior",
"status": "planning",
"dev_type": "docs",
"scope": null,
"package": null,
"priority": "P2",
"creator": "Apale",
"assignee": "Apale",
"createdAt": "2026-04-17",
"completedAt": null,
"branch": null,
"base_branch": "master",
"worktree_path": null,
"current_phase": 0,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-17-04-17-agent-first-cli-help-prd/prd.md",
"internal/cli/root.go",
"internal/cli/provider.go",
"internal/cli/alias.go",
"internal/cli/doctor.go",
"internal/cli/opencode.go",
"internal/cli/serve.go",
"README.md"
],
"notes": "This task defines an agent-first PRD for making olpx CLI help text a reliable configuration interface for AI agents.",
"meta": {
"primaryAudience": "ai-agent",
"priorityRule": "agent-first-manual-second",
"docSurface": "cobra-long-and-example"
}
}

View File

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

View File

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

166
README.md
View File

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

View File

@ -1,14 +1,14 @@
# opencode-provider-switch (`ops`)
# opencode-provider-switch (`olpx`)
A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one
stable model alias** routed to **multiple upstream providers** with
**deterministic failover**.
- Expose one custom provider `ops` to OpenCode.
- Configure logical aliases (`ops/gpt-5.4`, etc.).
- Expose one custom provider `olpx` to OpenCode.
- Configure logical aliases (`olpx/gpt-5.4`, etc.).
- Each alias has an ordered list of upstream `provider/model` targets.
- When the primary upstream returns `5xx`/`429`/connect error *before* any
stream bytes are flushed, `ops` transparently retries the next target.
stream bytes are flushed, `olpx` transparently retries the next target.
- Once a stream has started, the upstream is locked for the rest of that
request — no mid-stream splicing.
@ -17,35 +17,35 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
## Install
```bash
go build -o ops ./cmd/ops
go build -o olpx ./cmd/olpx
```
## Quick start
```bash
# 1. add upstream providers
ops provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
ops provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-...
olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
olpx provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-...
# 2. create alias and bind targets in priority order
ops alias add --name gpt-5.4
ops alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
ops alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx alias add --name gpt-5.4
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
# 3. push alias exposure into OpenCode global config
ops opencode sync
olpx opencode sync
# 4. run the proxy
ops serve
olpx serve
```
Inside OpenCode you can now pick `ops/gpt-5.4`.
Inside OpenCode you can now pick `olpx/gpt-5.4`.
### Import providers from an existing OpenCode config
```bash
ops provider import-opencode # reads global OpenCode config
ops provider import-opencode --from ./examples/opencode.jsonc
olpx provider import-opencode # reads global OpenCode config
olpx provider import-opencode --from ./examples/opencode.jsonc
```
The default import/sync target is the global user config only. It does not
@ -58,30 +58,30 @@ imported. Everything else is out of MVP scope.
### Doctor (static)
```bash
ops doctor
olpx doctor
```
Runs structural checks only — never issues real upstream requests.
## CLI reference
- `ops serve` — run the proxy
- `ops doctor` — validate config
- `ops provider {add,list,remove,import-opencode}`
- `ops alias {add,list,bind,unbind,remove}`
- `ops opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
- `olpx serve` — run the proxy
- `olpx doctor` — validate config
- `olpx provider {add,list,remove,import-opencode}`
- `olpx alias {add,list,bind,unbind,remove}`
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
Global flag: `--config PATH` (default `$XDG_CONFIG_HOME/ops/config.json`).
Global flag: `--config PATH` (default `$XDG_CONFIG_HOME/olpx/config.json`).
## Debug headers
Every proxied response includes:
- `X-OPS-Alias`
- `X-OPS-Provider`
- `X-OPS-Remote-Model`
- `X-OPS-Attempt`
- `X-OPS-Failover-Count`
- `X-OLPX-Alias`
- `X-OLPX-Provider`
- `X-OLPX-Remote-Model`
- `X-OLPX-Attempt`
- `X-OLPX-Failover-Count`
## Scope

View File

@ -1,4 +1,4 @@
// Command ops: local alias + failover proxy for OpenCode.
// Command olpx: local alias + failover proxy for OpenCode.
package main
import (

View File

@ -13,7 +13,7 @@ import (
func newAliasCmd() *cobra.Command {
c := &cobra.Command{
Use: "alias",
Short: "Manage logical aliases routed by ops",
Short: "Manage logical aliases routed by olpx",
}
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
return c
@ -53,7 +53,7 @@ func newAliasAddCmd() *cobra.Command {
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as ops/<name> in OpenCode (required)")
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as olpx/<name> in OpenCode (required)")
cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name")
cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state")
return cmd

View File

@ -9,7 +9,7 @@ import (
)
func TestProviderAddPreservesExistingFields(t *testing.T) {
t.Setenv("OPS_CONFIG", filepath.Join(t.TempDir(), "ops.json"))
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json"))
configPath = ""
cfg, err := loadCfg()
@ -56,8 +56,8 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
}
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "ops.json")
t.Setenv("OPS_CONFIG", configFile)
configFile := filepath.Join(t.TempDir(), "olpx.json")
t.Setenv("OLPX_CONFIG", configFile)
configPath = ""
cmd := newProviderAddCmd()
@ -75,7 +75,7 @@ func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
}
func TestAliasAddPreservesExistingFields(t *testing.T) {
t.Setenv("OPS_CONFIG", filepath.Join(t.TempDir(), "ops.json"))
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json"))
configPath = ""
cfg, err := loadCfg()

View File

@ -11,7 +11,7 @@ import (
func newDoctorCmd() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Validate ops config (static checks, no upstream requests)",
Short: "Validate olpx config (static checks, no upstream requests)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
@ -32,9 +32,9 @@ func newDoctorCmd() *cobra.Command {
aliasNames = append(aliasNames, a.Alias)
}
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
opencode.EnsureOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.ops invalid: %w", err))
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.olpx invalid: %w", err))
}
}
ok := len(issues) == 0
@ -55,7 +55,7 @@ func newDoctorCmd() *cobra.Command {
marker = "(exists)"
}
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.ops preview: valid=%v\n", ok)
fmt.Fprintf(cmd.OutOrStdout(), " provider.olpx preview: valid=%v\n", ok)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
if !ok {

View File

@ -24,8 +24,8 @@ func newOpencodeSyncCmd() *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "sync",
Short: "Update provider.ops in the global OpenCode config to match current aliases",
Long: `ops opencode sync writes provider.ops into the target OpenCode config.
Short: "Update provider.olpx in the global OpenCode config to match current aliases",
Long: `olpx opencode sync writes provider.olpx into the target OpenCode config.
By default it targets the global user config (~/.config/opencode), picking the
existing file in precedence order opencode.jsonc > opencode.json > config.json,
@ -59,7 +59,7 @@ or creating opencode.jsonc if none exists. It does NOT touch the top-level
aliasNames = append(aliasNames, a.Alias)
}
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
changed := opencode.EnsureOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if setModel != "" {
if raw["model"] != setModel {
raw["model"] = setModel
@ -83,7 +83,7 @@ or creating opencode.jsonc if none exists. It does NOT touch the top-level
if err := opencode.Save(path, raw); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ops into %s (%d alias(es))\n", path, len(aliasNames))
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.olpx into %s (%d alias(es))\n", path, len(aliasNames))
if setModel != "" {
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
}

View File

@ -1,4 +1,4 @@
// Package cli wires the ops cobra command tree.
// Package cli wires the olpx cobra command tree.
package cli
import (
@ -13,21 +13,21 @@ import (
// configPath is populated from the global --config flag.
var configPath string
// loadCfg opens the active ops config, with the selected path.
// loadCfg opens the active olpx config, with the selected path.
func loadCfg() (*config.Config, error) {
return config.Load(configPath)
}
// NewRootCmd builds the root ops command.
// NewRootCmd builds the root olpx command.
func NewRootCmd(version string) *cobra.Command {
root := &cobra.Command{
Use: "ops",
Short: "opencode-provider-switch: local alias + failover proxy for OpenCode",
Use: "olpx",
Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode",
SilenceUsage: true,
SilenceErrors: false,
Version: version,
}
root.PersistentFlags().StringVar(&configPath, "config", "", "path to ops config.json (default: $XDG_CONFIG_HOME/ops/config.json)")
root.PersistentFlags().StringVar(&configPath, "config", "", "path to olpx config.json (default: $XDG_CONFIG_HOME/olpx/config.json)")
root.AddCommand(newServeCmd())
root.AddCommand(newDoctorCmd())

View File

@ -12,7 +12,7 @@ import (
func newServeCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Run the local ops proxy (alias → failover upstream)",
Short: "Run the local olpx proxy (alias -> failover upstream)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {

View File

@ -1,4 +1,4 @@
// Package config manages the local ops JSON config file.
// Package config manages the local olpx JSON config file.
package config
import (
@ -43,7 +43,7 @@ type Server struct {
APIKey string `json:"api_key"`
}
// Config is the on-disk ops config.
// Config is the on-disk olpx config.
type Config struct {
Server Server `json:"server"`
Providers []Provider `json:"providers"`
@ -72,26 +72,26 @@ func Default() *Config {
Server: Server{
Host: "127.0.0.1",
Port: 9982,
APIKey: "ops-local",
APIKey: "olpx-local",
},
Providers: []Provider{},
Aliases: []Alias{},
}
}
// DefaultPath returns ~/.config/ops/config.json (XDG aware).
// DefaultPath returns ~/.config/olpx/config.json (XDG aware).
func DefaultPath() string {
if p := os.Getenv("OPS_CONFIG"); p != "" {
if p := os.Getenv("OLPX_CONFIG"); p != "" {
return p
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "ops", "config.json")
return filepath.Join(xdg, "olpx", "config.json")
}
home, err := os.UserHomeDir()
if err != nil {
return "ops-config.json"
return "olpx-config.json"
}
return filepath.Join(home, ".config", "ops", "config.json")
return filepath.Join(home, ".config", "olpx", "config.json")
}
// Load reads the config at path. Missing file returns a default config anchored to path.
@ -121,7 +121,7 @@ func Load(path string) (*Config, error) {
c.Server.Port = 9982
}
if c.Server.APIKey == "" {
c.Server.APIKey = "ops-local"
c.Server.APIKey = "olpx-local"
}
c.path = path
return c, nil

View File

@ -1,5 +1,5 @@
// Package opencode reads and writes OpenCode config files, including the
// `provider.ops` sync path. Files may be JSON or JSONC; we preserve the
// `provider.olpx` sync path. Files may be JSON or JSONC; we preserve the
// detected extension on write.
package opencode
@ -71,8 +71,8 @@ func Load(path string) (Raw, error) {
return out, nil
}
// Save writes provider.ops back to path. Existing files are normalized to plain
// JSON and only the provider.ops subtree is patched so unrelated key order stays
// Save writes provider.olpx back to path. Existing files are normalized to plain
// JSON and only the provider.olpx subtree is patched so unrelated key order stays
// stable. New files are still written from the full Raw object. Writes are
// atomic.
func Save(path string, raw Raw) error {
@ -101,7 +101,7 @@ func renderSaveData(path string, raw Raw) ([]byte, error) {
if len(bytes.TrimSpace(original)) == 0 {
return marshalRaw(raw)
}
patched, err := patchProviderOpsDocument(original, raw)
patched, err := patchProviderOLPXDocument(original, raw)
if err != nil {
return nil, fmt.Errorf("patch %s: %w", path, err)
}
@ -121,8 +121,8 @@ func marshalRaw(raw Raw) ([]byte, error) {
return data, nil
}
func patchProviderOpsDocument(original []byte, raw Raw) ([]byte, error) {
opsRaw, ok := opsProviderValue(raw)
func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
olpxRaw, ok := olpxProviderValue(raw)
if !ok {
return marshalRaw(raw)
}
@ -143,7 +143,7 @@ func patchProviderOpsDocument(original []byte, raw Raw) ([]byte, error) {
}
provider := root.findMember("provider")
if provider == nil {
return insertObjectMember(normalized, root, "provider", map[string]any{"ops": opsRaw})
return insertObjectMember(normalized, root, "provider", map[string]any{"olpx": olpxRaw})
}
if normalized[provider.valueStart] != '{' {
return nil, fmt.Errorf("top-level provider must be an object")
@ -152,23 +152,23 @@ func patchProviderOpsDocument(original []byte, raw Raw) ([]byte, error) {
if err != nil {
return nil, err
}
ops := providerObj.findMember("ops")
if ops == nil {
return insertObjectMember(normalized, providerObj, "ops", opsRaw)
olpx := providerObj.findMember("olpx")
if olpx == nil {
return insertObjectMember(normalized, providerObj, "olpx", olpxRaw)
}
return replaceObjectMember(normalized, *ops, opsRaw)
return replaceObjectMember(normalized, *olpx, olpxRaw)
}
func opsProviderValue(raw Raw) (map[string]any, bool) {
func olpxProviderValue(raw Raw) (map[string]any, bool) {
providerRaw, _ := raw["provider"].(map[string]any)
if providerRaw == nil {
return nil, false
}
opsRaw, _ := providerRaw["ops"].(map[string]any)
if opsRaw == nil {
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
if olpxRaw == nil {
return nil, false
}
return opsRaw, true
return olpxRaw, true
}
type objectSpan struct {
@ -409,11 +409,11 @@ func lineIndent(data []byte, pos int) string {
return string(data[lineStart:lineEnd])
}
// EnsureOpsProvider updates (or creates) the provider.ops entry with the given
// local base URL, local api key and alias set. Existing keys on provider.ops
// EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given
// local base URL, local api key and alias set. Existing keys on provider.olpx
// are preserved unless they conflict with the sync intent. Returns true if the
// file would actually change.
func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
changed := false
if _, ok := raw["$schema"]; !ok {
raw["$schema"] = "https://opencode.ai/config.json"
@ -425,22 +425,22 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
raw["provider"] = provRaw
changed = true
}
opsRaw, _ := provRaw["ops"].(map[string]any)
if opsRaw == nil {
opsRaw = map[string]any{}
provRaw["ops"] = opsRaw
olpxRaw, _ := provRaw["olpx"].(map[string]any)
if olpxRaw == nil {
olpxRaw = map[string]any{}
provRaw["olpx"] = olpxRaw
changed = true
}
if setIfDiff(opsRaw, "npm", "@ai-sdk/openai") {
if setIfDiff(olpxRaw, "npm", "@ai-sdk/openai") {
changed = true
}
if setIfDiff(opsRaw, "name", "OPS") {
if setIfDiff(olpxRaw, "name", "OpenCode LocalProxy CLI") {
changed = true
}
opts, _ := opsRaw["options"].(map[string]any)
opts, _ := olpxRaw["options"].(map[string]any)
if opts == nil {
opts = map[string]any{}
opsRaw["options"] = opts
olpxRaw["options"] = opts
changed = true
}
if setIfDiff(opts, "baseURL", baseURL) {
@ -454,7 +454,7 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
}
// Build models map from alias list. Preserve any existing per-model extras
// if the alias key matches; drop aliases removed locally.
existingModels, _ := opsRaw["models"].(map[string]any)
existingModels, _ := olpxRaw["models"].(map[string]any)
newModels := map[string]any{}
aliasSet := map[string]bool{}
for _, a := range aliases {
@ -477,43 +477,43 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
}
}
if !mapsEqualShallow(existingModels, newModels) {
opsRaw["models"] = newModels
olpxRaw["models"] = newModels
}
return changed
}
// ValidateOpsProvider checks that provider.ops matches the MVP sync contract.
func ValidateOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
// ValidateOLPXProvider checks that provider.olpx matches the MVP sync contract.
func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
provRaw, _ := raw["provider"].(map[string]any)
if provRaw == nil {
return fmt.Errorf("missing provider object")
}
opsRaw, _ := provRaw["ops"].(map[string]any)
if opsRaw == nil {
return fmt.Errorf("missing provider.ops")
olpxRaw, _ := provRaw["olpx"].(map[string]any)
if olpxRaw == nil {
return fmt.Errorf("missing provider.olpx")
}
if npm, _ := opsRaw["npm"].(string); npm != "@ai-sdk/openai" {
return fmt.Errorf("provider.ops.npm must be @ai-sdk/openai")
if npm, _ := olpxRaw["npm"].(string); npm != "@ai-sdk/openai" {
return fmt.Errorf("provider.olpx.npm must be @ai-sdk/openai")
}
if name, _ := opsRaw["name"].(string); name != "OPS" {
return fmt.Errorf("provider.ops.name must be OPS")
if name, _ := olpxRaw["name"].(string); name != "OpenCode LocalProxy CLI" {
return fmt.Errorf("provider.olpx.name must be OpenCode LocalProxy CLI")
}
opts, _ := opsRaw["options"].(map[string]any)
opts, _ := olpxRaw["options"].(map[string]any)
if opts == nil {
return fmt.Errorf("provider.ops.options missing")
return fmt.Errorf("provider.olpx.options missing")
}
if got, _ := opts["baseURL"].(string); got != baseURL {
return fmt.Errorf("provider.ops.options.baseURL mismatch")
return fmt.Errorf("provider.olpx.options.baseURL mismatch")
}
if got, _ := opts["apiKey"].(string); got != apiKey {
return fmt.Errorf("provider.ops.options.apiKey mismatch")
return fmt.Errorf("provider.olpx.options.apiKey mismatch")
}
if got, ok := opts["setCacheKey"].(bool); !ok || !got {
return fmt.Errorf("provider.ops.options.setCacheKey must be true")
return fmt.Errorf("provider.olpx.options.setCacheKey must be true")
}
models, _ := opsRaw["models"].(map[string]any)
models, _ := olpxRaw["models"].(map[string]any)
if models == nil {
return fmt.Errorf("provider.ops.models missing")
return fmt.Errorf("provider.olpx.models missing")
}
expected := append([]string(nil), aliases...)
sort.Strings(expected)
@ -521,20 +521,20 @@ func ValidateOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) erro
for alias, v := range models {
modelCfg, _ := v.(map[string]any)
if modelCfg == nil {
return fmt.Errorf("provider.ops.models.%s must be an object", alias)
return fmt.Errorf("provider.olpx.models.%s must be an object", alias)
}
if got, _ := modelCfg["name"].(string); got != alias {
return fmt.Errorf("provider.ops.models.%s.name mismatch", alias)
return fmt.Errorf("provider.olpx.models.%s.name mismatch", alias)
}
actual = append(actual, alias)
}
sort.Strings(actual)
if len(actual) != len(expected) {
return fmt.Errorf("provider.ops.models alias set mismatch")
return fmt.Errorf("provider.olpx.models alias set mismatch")
}
for i := range actual {
if actual[i] != expected[i] {
return fmt.Errorf("provider.ops.models alias set mismatch")
return fmt.Errorf("provider.olpx.models alias set mismatch")
}
}
return nil
@ -560,13 +560,13 @@ type ImportableProvider struct {
}
// ImportCustomProviders scans raw for @ai-sdk/openai custom providers that
// declare baseURL and an apiKey-compatible setting. The `ops` id itself is
// declare baseURL and an apiKey-compatible setting. The `olpx` id itself is
// skipped so sync output is not re-imported.
func ImportCustomProviders(raw Raw) []ImportableProvider {
out := []ImportableProvider{}
provRaw, _ := raw["provider"].(map[string]any)
for id, v := range provRaw {
if id == "ops" {
if id == "olpx" {
continue
}
m, ok := v.(map[string]any)

View File

@ -64,76 +64,76 @@ func TestResolveGlobalConfigPathPrecedence(t *testing.T) {
}
}
func TestValidateOpsProvider(t *testing.T) {
func TestValidateOLPXProvider(t *testing.T) {
raw := Raw{}
aliases := []string{"gpt-5.4", "gpt-5.4-mini"}
baseURL := "http://127.0.0.1:9982/v1"
apiKey := "ops-local"
EnsureOpsProvider(raw, baseURL, apiKey, aliases)
apiKey := "olpx-local"
EnsureOLPXProvider(raw, baseURL, apiKey, aliases)
providerRaw, _ := raw["provider"].(map[string]any)
opsRaw, _ := providerRaw["ops"].(map[string]any)
opts, _ := opsRaw["options"].(map[string]any)
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
opts, _ := olpxRaw["options"].(map[string]any)
if got, ok := opts["setCacheKey"].(bool); !ok || !got {
t.Fatalf("provider.ops.options.setCacheKey = %#v, want true", opts["setCacheKey"])
t.Fatalf("provider.olpx.options.setCacheKey = %#v, want true", opts["setCacheKey"])
}
if err := ValidateOpsProvider(raw, baseURL, apiKey, aliases); err != nil {
t.Fatalf("ValidateOpsProvider() unexpected error: %v", err)
if err := ValidateOLPXProvider(raw, baseURL, apiKey, aliases); err != nil {
t.Fatalf("ValidateOLPXProvider() unexpected error: %v", err)
}
}
func TestRenderSaveDataReplacesExistingProviderOpsOnly(t *testing.T) {
func TestRenderSaveDataReplacesExistingProviderOLPXOnly(t *testing.T) {
raw := Raw{
"$schema": "https://opencode.ai/config.json",
"model": "ops/gpt-5.4",
"model": "olpx/gpt-5.4",
"provider": map[string]any{
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
"ops": map[string]any{
"olpx": map[string]any{
"npm": "@ai-sdk/openai",
"name": "OPS",
"name": "OpenCode LocalProxy CLI",
"options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "ops-local",
"apiKey": "olpx-local",
"setCacheKey": true,
},
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
},
"openai": map[string]any{"npm": "@ai-sdk/openai"},
},
"small_model": "ops/gpt-5.4-mini",
"small_model": "olpx/gpt-5.4-mini",
}
original := []byte("{\n \"model\": \"ops/old\",\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"ops\": {\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\": \"ops/old-mini\"\n}\n")
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")
got, err := patchProviderOpsDocument(original, raw)
got, err := patchProviderOLPXDocument(original, raw)
if err != nil {
t.Fatalf("patchProviderOpsDocument() error: %v", err)
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
}
assertValidJSON(t, got)
assertStringOrder(t, string(got), []string{`"model"`, `"provider"`, `"small_model"`})
assertStringOrder(t, string(got), []string{`"anthropic"`, `"ops"`, `"openai"`})
assertStringOrder(t, string(got), []string{`"anthropic"`, `"olpx"`, `"openai"`})
if strings.Contains(string(got), `"npm": "old"`) {
t.Fatalf("old provider.ops content still present: %s", string(got))
t.Fatalf("old provider.olpx content still present: %s", string(got))
}
var saved Raw
if err := json.Unmarshal(got, &saved); err != nil {
t.Fatalf("unmarshal patched json: %v", err)
}
if err := ValidateOpsProvider(saved, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOpsProvider(saved) error: %v", err)
if err := ValidateOLPXProvider(saved, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOLPXProvider(saved) error: %v", err)
}
}
func TestRenderSaveDataInsertsOpsWithoutReorderingProviderKeys(t *testing.T) {
func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(t *testing.T) {
raw := Raw{
"provider": map[string]any{
"anthropic": map[string]any{"npm": "@ai-sdk/anthropic"},
"ops": map[string]any{
"olpx": map[string]any{
"npm": "@ai-sdk/openai",
"name": "OPS",
"name": "OpenCode LocalProxy CLI",
"options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "ops-local",
"apiKey": "olpx-local",
"setCacheKey": true,
},
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
@ -141,38 +141,38 @@ func TestRenderSaveDataInsertsOpsWithoutReorderingProviderKeys(t *testing.T) {
"openai": map[string]any{"npm": "@ai-sdk/openai"},
},
}
original := []byte("{\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"model\": \"ops/gpt-5.4\"\n}\n")
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")
got, err := patchProviderOpsDocument(original, raw)
got, err := patchProviderOLPXDocument(original, raw)
if err != nil {
t.Fatalf("patchProviderOpsDocument() error: %v", err)
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
}
assertValidJSON(t, got)
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"ops"`})
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"olpx"`})
}
func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) {
raw := Raw{
"model": "ops/gpt-5.4",
"model": "olpx/gpt-5.4",
"provider": map[string]any{
"ops": map[string]any{
"olpx": map[string]any{
"npm": "@ai-sdk/openai",
"name": "OPS",
"name": "OpenCode LocalProxy CLI",
"options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "ops-local",
"apiKey": "olpx-local",
"setCacheKey": true,
},
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
},
},
"small_model": "ops/gpt-5.4-mini",
"small_model": "olpx/gpt-5.4-mini",
}
original := []byte("{\n \"model\": \"ops/gpt-5.4\",\n \"small_model\": \"ops/gpt-5.4-mini\"\n}\n")
original := []byte("{\n \"model\": \"olpx/gpt-5.4\",\n \"small_model\": \"olpx/gpt-5.4-mini\"\n}\n")
got, err := patchProviderOpsDocument(original, raw)
got, err := patchProviderOLPXDocument(original, raw)
if err != nil {
t.Fatalf("patchProviderOpsDocument() error: %v", err)
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
}
assertValidJSON(t, got)
assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`})
@ -181,12 +181,12 @@ func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) {
func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
raw := Raw{
"provider": map[string]any{
"ops": map[string]any{
"olpx": map[string]any{
"npm": "@ai-sdk/openai",
"name": "OPS",
"name": "OpenCode LocalProxy CLI",
"options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "ops-local",
"apiKey": "olpx-local",
"setCacheKey": true,
},
"models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}},
@ -195,9 +195,9 @@ func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
}
original := []byte("{\n // comment\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"},\n },\n}\n")
got, err := patchProviderOpsDocument(original, raw)
got, err := patchProviderOLPXDocument(original, raw)
if err != nil {
t.Fatalf("patchProviderOpsDocument() error: %v", err)
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
}
assertValidJSON(t, got)
if bytes.Contains(got, []byte("// comment")) {
@ -208,51 +208,51 @@ func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) {
func TestRenderSaveDataRejectsInvalidJSONC(t *testing.T) {
raw := Raw{
"provider": map[string]any{
"ops": map[string]any{
"olpx": map[string]any{
"npm": "@ai-sdk/openai",
"name": "OPS",
"name": "OpenCode LocalProxy CLI",
"options": map[string]any{
"baseURL": "http://127.0.0.1:9982/v1",
"apiKey": "ops-local",
"apiKey": "olpx-local",
"setCacheKey": true,
},
},
},
}
if _, err := patchProviderOpsDocument([]byte(`{"provider": {`), raw); err == nil {
if _, err := patchProviderOLPXDocument([]byte(`{"provider": {`), raw); err == nil {
t.Fatal("expected invalid json/jsonc error")
}
}
func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) {
raw := Raw{}
EnsureOpsProvider(raw, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"})
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
if _, err := patchProviderOpsDocument([]byte(`{"provider":"bad"}`), raw); err == nil {
if _, err := patchProviderOLPXDocument([]byte(`{"provider":"bad"}`), raw); err == nil {
t.Fatal("expected provider object error")
}
}
func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) {
raw := Raw{}
EnsureOpsProvider(raw, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"})
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
if _, err := patchProviderOpsDocument([]byte(`[]`), raw); err == nil {
if _, err := patchProviderOLPXDocument([]byte(`[]`), raw); err == nil {
t.Fatal("expected top-level object error")
}
if _, err := patchProviderOpsDocument([]byte("{} trailing"), raw); err == nil {
if _, err := patchProviderOLPXDocument([]byte("{} trailing"), raw); err == nil {
t.Fatal("expected single top-level object error")
}
}
func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "opencode.jsonc")
if err := os.WriteFile(path, []byte("{\n \"model\": \"ops/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\": \"olpx/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil {
t.Fatalf("write seed config: %v", err)
}
raw := Raw{}
EnsureOpsProvider(raw, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"})
EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"})
if err := Save(path, raw); err != nil {
t.Fatalf("Save() error: %v", err)
@ -266,8 +266,8 @@ func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) {
if err != nil {
t.Fatalf("Load(saved) error: %v", err)
}
if err := ValidateOpsProvider(loaded, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOpsProvider(loaded) error: %v", err)
if err := ValidateOLPXProvider(loaded, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
t.Fatalf("ValidateOLPXProvider(loaded) error: %v", err)
}
}

View File

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

View File

@ -18,10 +18,10 @@ func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
t.Parallel()
srv := New(&config.Config{
Server: config.Server{APIKey: "ops-local"},
Server: config.Server{APIKey: "olpx-local"},
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`))
req.Header.Set("Authorization", "Bearer ops-local")
req.Header.Set("Authorization", "Bearer olpx-local")
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
@ -64,7 +64,7 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
defer second.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: "ops-local"},
Server: config.Server{APIKey: "olpx-local"},
Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"},
{ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"},
@ -76,8 +76,8 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
}},
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"ops/gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer ops-local")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"olpx/gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer olpx-local")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder()
@ -96,14 +96,14 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
if secondSeenModel != "up-2" {
t.Fatalf("second upstream model = %q, want up-2", secondSeenModel)
}
if got := rr.Header().Get("X-OPS-Attempt"); got != "2" {
t.Fatalf("X-OPS-Attempt = %q, want 2", got)
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" {
t.Fatalf("X-OLPX-Attempt = %q, want 2", got)
}
if got := rr.Header().Get("X-OPS-Failover-Count"); got != "1" {
t.Fatalf("X-OPS-Failover-Count = %q, want 1", got)
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" {
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got)
}
if got := rr.Header().Get("X-OPS-Provider"); got != "p2" {
t.Fatalf("X-OPS-Provider = %q, want p2", got)
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
}
}
@ -124,7 +124,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
defer second.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: "ops-local"},
Server: config.Server{APIKey: "olpx-local"},
Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1"},
{ID: "p2", BaseURL: second.URL + "/v1"},
@ -137,7 +137,7 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer ops-local")
req.Header.Set("Authorization", "Bearer olpx-local")
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
@ -149,8 +149,8 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
if calledSecond {
t.Fatal("second upstream should not be called for 400 response")
}
if got := rr.Header().Get("X-OPS-Provider"); got != "p1" {
t.Fatalf("X-OPS-Provider = %q, want p1", got)
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" {
t.Fatalf("X-OLPX-Provider = %q, want p1", got)
}
if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` {
t.Fatalf("body = %q", body)