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:
parent
37c6ee0640
commit
7c30e9d5eb
607
.trellis/tasks/04-17-04-17-agent-first-cli-help-prd/prd.md
Normal file
607
.trellis/tasks/04-17-04-17-agent-first-cli-help-prd/prd.md
Normal 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.
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
# OPS MVP Redesign
|
# OLPX MVP Redesign
|
||||||
|
|
||||||
## Summary
|
## 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
|
- 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 `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
|
## 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 `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
|
## High-Level Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
OpenCode
|
OpenCode
|
||||||
-> custom provider `ops` (@ai-sdk/openai)
|
-> custom provider `olpx` (@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: `ops`
|
- provider id: `olpx`
|
||||||
- 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 `ops-local`
|
- local API key: static placeholder such as `olpx-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": {
|
||||||
"ops": {
|
"olpx": {
|
||||||
"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": "ops-local"
|
"apiKey": "olpx-local"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"gpt-5.4": {
|
"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 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:
|
Simplest MVP path:
|
||||||
|
|
||||||
- keep alias list in `ops`
|
- keep alias list in `olpx`
|
||||||
- sync alias list into OpenCode `provider.ops.models`
|
- sync alias list into OpenCode `provider.olpx.models`
|
||||||
- make sure `provider.ops` is valid enough to appear as a connected runtime provider
|
- make sure `provider.olpx` is valid enough to appear as a connected runtime provider
|
||||||
- let OpenCode surface `ops/<alias>` in `/models` and `/model`
|
- let OpenCode surface `olpx/<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, `ops` should only support a narrow integration step:
|
Instead, `olpx` should only support a narrow integration step:
|
||||||
|
|
||||||
- ensure `provider.ops` exists or is updated
|
- ensure `provider.olpx` exists or is updated
|
||||||
- optionally sync alias entries into `provider.ops.models`
|
- optionally sync alias entries into `provider.olpx.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:
|
||||||
|
|
||||||
- `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
|
- 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
|
||||||
|
|
||||||
`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:
|
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
|
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:
|
||||||
|
|
||||||
- `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
|
- `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 `ops`.
|
4. Alias name must be unique within `olpx`.
|
||||||
5. OpenCode should reference alias as `ops/<alias>`.
|
5. OpenCode should reference alias as `olpx/<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 `ops` alias.
|
4. Treat `model` as `olpx` 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, `ops` should add response headers where possible:
|
For debugging, `olpx` should add response headers where possible:
|
||||||
|
|
||||||
- `X-OPS-Alias`
|
- `X-OLPX-Alias`
|
||||||
- `X-OPS-Provider`
|
- `X-OLPX-Provider`
|
||||||
- `X-OPS-Remote-Model`
|
- `X-OLPX-Remote-Model`
|
||||||
- `X-OPS-Attempt`
|
- `X-OLPX-Attempt`
|
||||||
- `X-OPS-Failover-Count`
|
- `X-OLPX-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 `ops` JSON or JSONC config file for:
|
Use one local `olpx` JSON or JSONC config file for:
|
||||||
|
|
||||||
- upstream providers
|
- upstream providers
|
||||||
- aliases
|
- aliases
|
||||||
@ -341,19 +341,19 @@ Recommended MVP commands:
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
- `ops serve`
|
- `olpx serve`
|
||||||
- `ops doctor`
|
- `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:
|
It should validate:
|
||||||
|
|
||||||
- local `ops` config can be loaded
|
- local `olpx` 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.ops` config shape is structurally valid
|
- generated or synced `provider.olpx` 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 `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
|
### Provider Management
|
||||||
|
|
||||||
- `ops provider add`
|
- `olpx provider add`
|
||||||
- `ops provider list`
|
- `olpx provider list`
|
||||||
- `ops provider remove`
|
- `olpx provider remove`
|
||||||
- `ops provider import-opencode`
|
- `olpx provider import-opencode`
|
||||||
|
|
||||||
### Alias Management
|
### Alias Management
|
||||||
|
|
||||||
- `ops alias add`
|
- `olpx alias add`
|
||||||
- `ops alias list`
|
- `olpx alias list`
|
||||||
- `ops alias bind`
|
- `olpx alias bind`
|
||||||
- `ops alias unbind`
|
- `olpx alias unbind`
|
||||||
- `ops alias remove`
|
- `olpx alias remove`
|
||||||
|
|
||||||
### OpenCode Integration
|
### OpenCode Integration
|
||||||
|
|
||||||
- `ops opencode sync`
|
- `olpx opencode sync`
|
||||||
|
|
||||||
## `ops opencode sync` Responsibility
|
## `olpx opencode sync` Responsibility
|
||||||
|
|
||||||
This command should do one narrow job:
|
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`
|
- 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.ops.models`
|
- sync current alias names into `provider.olpx.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.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
|
## 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 `ops`
|
- use static local placeholder API key between OpenCode and `olpx`
|
||||||
- store upstream credentials in local `ops` config
|
- store upstream credentials in local `olpx` 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 `ops opencode sync` and see alias names appear in OpenCode `opencode models` output and `/model` picker
|
3. run `olpx 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
|
4. select `olpx/<alias>` in OpenCode without exposing concrete upstream model IDs
|
||||||
5. send normal streaming OpenAI Responses traffic through `ops`
|
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
|
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
|
||||||
|
|
||||||
- `ops opencode sync`
|
- `olpx 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 `ops doctor`
|
- connected-provider validation in `olpx doctor`
|
||||||
|
|
||||||
### Phase 3
|
### Phase 3
|
||||||
|
|
||||||
@ -469,16 +469,16 @@ MVP is successful if a user can:
|
|||||||
|
|
||||||
### Phase 4
|
### Phase 4
|
||||||
|
|
||||||
- `ops doctor`
|
- `olpx 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. `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.
|
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
|
## Strong Recommendation
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# OPS MVP Design
|
# OLPX MVP Design
|
||||||
|
|
||||||
## Summary
|
## 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:
|
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 `ops` config file
|
- Manage state in SQLite, not in an `olpx` 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 `ops`.
|
2. Reduce OpenCode config complexity by centralizing provider management in `olpx`.
|
||||||
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
|
||||||
|
|
||||||
`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
|
- 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
|
||||||
-> ops proxy (127.0.0.1:9982)
|
-> olpx 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, `ops` should expose two local providers to OpenCode:
|
For MVP, `olpx` 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 `ops`.
|
This mirrors how OpenCode expects provider wiring today and avoids hidden protocol translation logic inside `olpx`.
|
||||||
|
|
||||||
## OpenCode Integration Strategy
|
## OpenCode Integration Strategy
|
||||||
|
|
||||||
### Generated OpenCode Config
|
### 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:
|
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": {
|
||||||
"ops-chat": {
|
"olpx-chat": {
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
"name": "OPS Chat",
|
"name": "OLPX Chat",
|
||||||
"options": {
|
"options": {
|
||||||
"baseURL": "http://127.0.0.1:9982/v1",
|
"baseURL": "http://127.0.0.1:9982/v1",
|
||||||
"apiKey": "ops-local"
|
"apiKey": "olpx-local"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"gpt-5.4": {
|
"gpt-5.4": {
|
||||||
@ -159,12 +159,12 @@ Generated provider shape should be conceptually like this:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ops-responses": {
|
"olpx-responses": {
|
||||||
"npm": "@ai-sdk/openai",
|
"npm": "@ai-sdk/openai",
|
||||||
"name": "OPS Responses",
|
"name": "OLPX Responses",
|
||||||
"options": {
|
"options": {
|
||||||
"baseURL": "http://127.0.0.1:9982/v1",
|
"baseURL": "http://127.0.0.1:9982/v1",
|
||||||
"apiKey": "ops-local"
|
"apiKey": "olpx-local"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"gpt-5.4": {
|
"gpt-5.4": {
|
||||||
@ -173,16 +173,16 @@ Generated provider shape should be conceptually like this:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model": "ops-responses/gpt-5.4",
|
"model": "olpx-responses/gpt-5.4",
|
||||||
"small_model": "ops-chat/gpt-5.4-mini"
|
"small_model": "olpx-chat/gpt-5.4-mini"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Important Rule
|
### 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.
|
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 `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:
|
MVP response:
|
||||||
|
|
||||||
- document this clearly
|
- document this clearly
|
||||||
- make `ops doctor` detect likely overrides
|
- make `olpx 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
|
||||||
|
|
||||||
`ops install` should inspect:
|
`olpx 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 `ops-*` providers for OpenCode.
|
5. Generate local `olpx-*` 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, `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
|
## 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:
|
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
|
- 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
|
### 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 `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
|
### Important Constraint
|
||||||
|
|
||||||
@ -386,13 +386,13 @@ Reduce manual provider setup work.
|
|||||||
|
|
||||||
### Behavior
|
### 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:
|
Useful commands:
|
||||||
|
|
||||||
- `ops provider models sync <provider>`
|
- `olpx provider models sync <provider>`
|
||||||
- `ops provider models list <provider>`
|
- `olpx provider models list <provider>`
|
||||||
- `ops alias suggest <provider>`
|
- `olpx 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, `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
|
## 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-OPS-Protocol`
|
- `X-OLPX-Protocol`
|
||||||
- `X-OPS-Alias`
|
- `X-OLPX-Alias`
|
||||||
- `X-OPS-Provider`
|
- `X-OLPX-Provider`
|
||||||
- `X-OPS-Remote-Model`
|
- `X-OLPX-Remote-Model`
|
||||||
- `X-OPS-Attempt`
|
- `X-OLPX-Attempt`
|
||||||
- `X-OPS-Failover-Count`
|
- `X-OLPX-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 `ops-local`
|
- generated OpenCode config uses local static API key like `olpx-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
|
||||||
|
|
||||||
- `ops init`
|
- `olpx init`
|
||||||
- `ops serve`
|
- `olpx serve`
|
||||||
- `ops doctor`
|
- `olpx doctor`
|
||||||
- `ops install`
|
- `olpx install`
|
||||||
- `ops restore`
|
- `olpx restore`
|
||||||
|
|
||||||
### Provider Management
|
### Provider Management
|
||||||
|
|
||||||
- `ops provider add`
|
- `olpx provider add`
|
||||||
- `ops provider list`
|
- `olpx provider list`
|
||||||
- `ops provider edit`
|
- `olpx provider edit`
|
||||||
- `ops provider remove`
|
- `olpx provider remove`
|
||||||
- `ops provider enable`
|
- `olpx provider enable`
|
||||||
- `ops provider disable`
|
- `olpx provider disable`
|
||||||
|
|
||||||
### Model Discovery
|
### Model Discovery
|
||||||
|
|
||||||
- `ops provider models sync <provider>`
|
- `olpx provider models sync <provider>`
|
||||||
- `ops provider models list <provider>`
|
- `olpx provider models list <provider>`
|
||||||
|
|
||||||
### Alias Management
|
### Alias Management
|
||||||
|
|
||||||
- `ops alias add`
|
- `olpx alias add`
|
||||||
- `ops alias list`
|
- `olpx alias list`
|
||||||
- `ops alias bind`
|
- `olpx alias bind`
|
||||||
- `ops alias unbind`
|
- `olpx alias unbind`
|
||||||
- `ops alias enable`
|
- `olpx alias enable`
|
||||||
- `ops alias disable`
|
- `olpx alias disable`
|
||||||
- `ops alias inspect <alias>`
|
- `olpx alias inspect <alias>`
|
||||||
|
|
||||||
### Diagnostics
|
### Diagnostics
|
||||||
|
|
||||||
- `ops logs tail`
|
- `olpx logs tail`
|
||||||
- `ops route test --protocol <protocol> --model <alias>`
|
- `olpx 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. `ops init`
|
1. `olpx init`
|
||||||
2. `ops provider add`
|
2. `olpx provider add`
|
||||||
3. `ops provider models sync`
|
3. `olpx provider models sync`
|
||||||
4. `ops alias add`
|
4. `olpx alias add`
|
||||||
5. `ops alias bind`
|
5. `olpx alias bind`
|
||||||
6. `ops install`
|
6. `olpx install`
|
||||||
7. `ops serve`
|
7. `olpx 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/ops/
|
cmd/olpx/
|
||||||
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 `ops` can actually back.
|
MVP should generate only providers that `olpx` can actually back.
|
||||||
|
|
||||||
That means:
|
That means:
|
||||||
|
|
||||||
- `ops-chat`
|
- `olpx-chat`
|
||||||
- `ops-responses`
|
- `olpx-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:
|
||||||
|
|
||||||
- `ops` becomes the stable local provider boundary
|
- `olpx` 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 `ops` in the **same environment** is supported.
|
3. Running OpenCode and `olpx` in the **same environment** is supported.
|
||||||
4. `ops doctor` helps detect config-path and loopback issues.
|
4. `olpx 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 `ops`.
|
1. Fully automatic cross-boundary migration between WSL OpenCode and Windows `olpx`.
|
||||||
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 `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.
|
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:
|
||||||
|
|
||||||
- `ops doctor`
|
- `olpx 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 `ops serve`.
|
4. Run `olpx 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
|
||||||
- `ops doctor`
|
- `olpx doctor`
|
||||||
- restore flow
|
- restore flow
|
||||||
|
|
||||||
## Strong Recommendation
|
## Strong Recommendation
|
||||||
|
|||||||
166
README.md
166
README.md
@ -1,11 +1,11 @@
|
|||||||
# opencode-provider-switch (`ops`)
|
# opencode-provider-switch (`olpx`)
|
||||||
|
|
||||||
`ops` 是一个给 OpenCode 使用的本地代理。
|
`olpx` 是 OpenCode LocalProxy CLI,给 OpenCode 使用的本地代理。
|
||||||
|
|
||||||
它解决的问题很简单:
|
它解决的问题很简单:
|
||||||
|
|
||||||
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `ops/gpt-5.4`
|
- 你在 OpenCode 里只使用一个稳定的模型名,例如 `olpx/gpt-5.4`
|
||||||
- `ops` 在本地把这个别名映射到多个上游 `provider/model`
|
- `olpx` 在本地把这个别名映射到多个上游 `provider/model`
|
||||||
- 按你配置的顺序依次尝试上游
|
- 按你配置的顺序依次尝试上游
|
||||||
- 如果主上游在响应开始前失败,自动切到下一个上游
|
- 如果主上游在响应开始前失败,自动切到下一个上游
|
||||||
|
|
||||||
@ -20,11 +20,11 @@
|
|||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
- 本地维护 `ops` 配置文件:上游 provider、alias、监听地址
|
- 本地维护 `olpx` 配置文件:上游 provider、alias、监听地址
|
||||||
- 支持手动添加 provider
|
- 支持手动添加 provider
|
||||||
- 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider
|
- 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider
|
||||||
- 支持创建 alias,并按顺序绑定多个上游 target
|
- 支持创建 alias,并按顺序绑定多个上游 target
|
||||||
- 支持把 alias 同步到 OpenCode 的 `provider.ops.models`
|
- 支持把 alias 同步到 OpenCode 的 `provider.olpx.models`
|
||||||
- 支持本地代理 `POST /v1/responses`
|
- 支持本地代理 `POST /v1/responses`
|
||||||
- 支持流式透传
|
- 支持流式透传
|
||||||
- 支持首字节前失败切换
|
- 支持首字节前失败切换
|
||||||
@ -43,30 +43,30 @@
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o ops ./cmd/ops
|
go build -o olpx ./cmd/olpx
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你只想临时运行,也可以直接:
|
如果你只想临时运行,也可以直接:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/ops --help
|
go run ./cmd/olpx --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5 分钟快速上手
|
## 5 分钟快速上手
|
||||||
|
|
||||||
### 1. 添加上游 provider
|
### 1. 添加上游 provider
|
||||||
|
|
||||||
`ops` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。
|
`olpx` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx
|
olpx 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 codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy
|
||||||
```
|
```
|
||||||
|
|
||||||
如果某个上游还需要额外请求头,可以重复传 `--header`:
|
如果某个上游还需要额外请求头,可以重复传 `--header`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider add \
|
olpx 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 \
|
||||||
@ -77,42 +77,42 @@ ops provider add \
|
|||||||
查看当前 provider:
|
查看当前 provider:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider list
|
olpx provider list
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 创建 alias,并绑定多个上游 target
|
### 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
|
```bash
|
||||||
ops alias add --name gpt-5.4 --display-name "GPT 5.4"
|
olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
|
||||||
ops alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
olpx 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 bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
||||||
```
|
```
|
||||||
|
|
||||||
查看当前 alias:
|
查看当前 alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops alias list
|
olpx alias list
|
||||||
```
|
```
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
|
|
||||||
- target 的顺序就是失败切换顺序
|
- target 的顺序就是失败切换顺序
|
||||||
- enabled alias 必须至少有一个 enabled target
|
- enabled alias 必须至少有一个 enabled target
|
||||||
- `ops alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
- `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
||||||
|
|
||||||
### 3. 先做一次静态检查
|
### 3. 先做一次静态检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops doctor
|
olpx doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
`ops doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
|
`olpx doctor` 只做静态校验,不会真的请求上游,不会消耗额度。
|
||||||
|
|
||||||
它会检查:
|
它会检查:
|
||||||
|
|
||||||
- 本地 `ops` 配置能不能正常加载
|
- 本地 `olpx` 配置能不能正常加载
|
||||||
- alias 是否引用了不存在的 provider
|
- alias 是否引用了不存在的 provider
|
||||||
- enabled alias 是否至少有一个 enabled target
|
- enabled alias 是否至少有一个 enabled target
|
||||||
- 本地代理监听地址是否合理
|
- 本地代理监听地址是否合理
|
||||||
@ -121,56 +121,56 @@ ops doctor
|
|||||||
### 4. 把 alias 同步到 OpenCode
|
### 4. 把 alias 同步到 OpenCode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync
|
olpx opencode sync
|
||||||
```
|
```
|
||||||
|
|
||||||
这个命令会做一件事:把当前 alias 列表同步进 OpenCode 的 `provider.ops.models`。
|
这个命令会做一件事:把当前 alias 列表同步进 OpenCode 的 `provider.olpx.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.ops`
|
- 只更新 `provider.olpx`
|
||||||
- 不会修改顶层 `model`
|
- 不会修改顶层 `model`
|
||||||
- 不会修改顶层 `small_model`
|
- 不会修改顶层 `small_model`
|
||||||
|
|
||||||
如果你希望顺手把默认模型也切到 `ops`,需要显式指定:
|
如果你希望顺手把默认模型也切到 `olpx`,需要显式指定:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync --set-model ops/gpt-5.4
|
olpx opencode sync --set-model olpx/gpt-5.4
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你还有小模型 alias,也可以这样:
|
如果你还有小模型 alias,也可以这样:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync \
|
olpx opencode sync \
|
||||||
--set-model ops/gpt-5.4 \
|
--set-model olpx/gpt-5.4 \
|
||||||
--set-small-model ops/gpt-5.4-mini
|
--set-small-model olpx/gpt-5.4-mini
|
||||||
```
|
```
|
||||||
|
|
||||||
先预览不写入:
|
先预览不写入:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync --dry-run
|
olpx opencode sync --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
写到指定 OpenCode 配置文件:
|
写到指定 OpenCode 配置文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync --target /path/to/opencode.jsonc
|
olpx opencode sync --target /path/to/opencode.jsonc
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 启动本地代理
|
### 5. 启动本地代理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops serve
|
olpx serve
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听地址:
|
默认监听地址:
|
||||||
|
|
||||||
- `127.0.0.1:9982`
|
- `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 里使用
|
### 6. 在 OpenCode 里使用
|
||||||
|
|
||||||
完成 `ops opencode sync` 后,你应该能在 OpenCode 里看到 `ops/<alias>`。
|
完成 `olpx opencode sync` 后,你应该能在 OpenCode 里看到 `olpx/<alias>`。
|
||||||
|
|
||||||
例如:
|
例如:
|
||||||
|
|
||||||
- `ops/gpt-5.4`
|
- `olpx/gpt-5.4`
|
||||||
|
|
||||||
如果你执行了:
|
如果你执行了:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync --set-model ops/gpt-5.4
|
olpx opencode sync --set-model olpx/gpt-5.4
|
||||||
```
|
```
|
||||||
|
|
||||||
那么 OpenCode 默认模型也会直接切到这个 alias。
|
那么 OpenCode 默认模型也会直接切到这个 alias。
|
||||||
|
|
||||||
## 直接验证本地代理
|
## 直接验证本地代理
|
||||||
|
|
||||||
如果你想先不走 OpenCode,直接验证 `ops` 是否能正常代理,可以自己发一个请求:
|
如果你想先不走 OpenCode,直接验证 `olpx` 是否能正常代理,可以自己发一个请求:
|
||||||
|
|
||||||
```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 ops-local" \
|
-H "Authorization: Bearer olpx-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`,不是 `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
|
||||||
|
|
||||||
如果你原来已经在 OpenCode 里配置过一些自定义 provider,可以直接导入:
|
如果你原来已经在 OpenCode 里配置过一些自定义 provider,可以直接导入:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider import-opencode
|
olpx provider import-opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
或者指定导入源:
|
或者指定导入源:
|
||||||
|
|
||||||
```bash
|
```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_CONFIG_DIR`
|
||||||
- 如果你要导入别的 OpenCode 配置文件,请显式传 `--from`
|
- 如果你要导入别的 OpenCode 配置文件,请显式传 `--from`
|
||||||
- 当前只导入 provider 的基本连接信息
|
- 当前只导入 provider 的基本连接信息
|
||||||
- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `ops provider add --header ...` 补齐
|
- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `olpx provider add --header ...` 补齐
|
||||||
- `ops` 自己不会被反向导入
|
- `olpx` 自己不会被反向导入
|
||||||
|
|
||||||
覆盖已存在的 provider:
|
覆盖已存在的 provider:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider import-opencode --overwrite
|
olpx provider import-opencode --overwrite
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
@ -251,53 +251,53 @@ ops provider import-opencode --overwrite
|
|||||||
添加或更新 provider:
|
添加或更新 provider:
|
||||||
|
|
||||||
```bash
|
```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:
|
查看 provider:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider list
|
olpx provider list
|
||||||
```
|
```
|
||||||
|
|
||||||
删除 provider:
|
删除 provider:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider remove <id>
|
olpx provider remove <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`ops doctor` 会报错。
|
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`olpx doctor` 会报错。
|
||||||
|
|
||||||
### alias
|
### alias
|
||||||
|
|
||||||
创建或更新 alias:
|
创建或更新 alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops alias add --name <alias>
|
olpx alias add --name <alias>
|
||||||
```
|
```
|
||||||
|
|
||||||
给 alias 追加一个 target:
|
给 alias 追加一个 target:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
|
olpx alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
|
||||||
```
|
```
|
||||||
|
|
||||||
解绑 target:
|
解绑 target:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops alias unbind --alias <alias> --provider <provider-id> --model <upstream-model>
|
olpx alias unbind --alias <alias> --provider <provider-id> --model <upstream-model>
|
||||||
```
|
```
|
||||||
|
|
||||||
查看 alias:
|
查看 alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops alias list
|
olpx alias list
|
||||||
```
|
```
|
||||||
|
|
||||||
删除 alias:
|
删除 alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops alias remove <alias>
|
olpx alias remove <alias>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 其他
|
### 其他
|
||||||
@ -305,42 +305,42 @@ ops alias remove <alias>
|
|||||||
静态检查:
|
静态检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops doctor
|
olpx doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
启动代理:
|
启动代理:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops serve
|
olpx serve
|
||||||
```
|
```
|
||||||
|
|
||||||
同步到 OpenCode:
|
同步到 OpenCode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops opencode sync
|
olpx opencode sync
|
||||||
```
|
```
|
||||||
|
|
||||||
全局帮助:
|
全局帮助:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops --help
|
olpx --help
|
||||||
ops provider --help
|
olpx provider --help
|
||||||
ops alias --help
|
olpx alias --help
|
||||||
ops opencode sync --help
|
olpx opencode sync --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置文件说明
|
## 配置文件说明
|
||||||
|
|
||||||
本地 `ops` 配置文件默认路径:
|
本地 `olpx` 配置文件默认路径:
|
||||||
|
|
||||||
- 如果设置了 `OPS_CONFIG`,优先使用它
|
- 如果设置了 `OLPX_CONFIG`,优先使用它
|
||||||
- 否则使用 `$XDG_CONFIG_HOME/ops/config.json`
|
- 否则使用 `$XDG_CONFIG_HOME/olpx/config.json`
|
||||||
- 再否则使用 `~/.config/ops/config.json`
|
- 再否则使用 `~/.config/olpx/config.json`
|
||||||
|
|
||||||
也可以对每个命令显式指定:
|
也可以对每个命令显式指定:
|
||||||
|
|
||||||
```bash
|
```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": {
|
"server": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 9982,
|
"port": 9982,
|
||||||
"api_key": "ops-local"
|
"api_key": "olpx-local"
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
{
|
{
|
||||||
@ -390,7 +390,7 @@ ops --config /path/to/config.json doctor
|
|||||||
|
|
||||||
## 失败切换规则
|
## 失败切换规则
|
||||||
|
|
||||||
`ops` 的切换规则很保守,也很容易理解。
|
`olpx` 的切换规则很保守,也很容易理解。
|
||||||
|
|
||||||
会切换到下一个 target 的情况:
|
会切换到下一个 target 的情况:
|
||||||
|
|
||||||
@ -421,11 +421,11 @@ ops --config /path/to/config.json doctor
|
|||||||
|
|
||||||
每次成功代理或透传上游错误时,响应里都会附带这些头:
|
每次成功代理或透传上游错误时,响应里都会附带这些头:
|
||||||
|
|
||||||
- `X-OPS-Alias`
|
- `X-OLPX-Alias`
|
||||||
- `X-OPS-Provider`
|
- `X-OLPX-Provider`
|
||||||
- `X-OPS-Remote-Model`
|
- `X-OLPX-Remote-Model`
|
||||||
- `X-OPS-Attempt`
|
- `X-OLPX-Attempt`
|
||||||
- `X-OPS-Failover-Count`
|
- `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 状态
|
2. 你的 alias 是否是 enabled 状态
|
||||||
3. alias 是否至少绑定了一个 enabled target
|
3. alias 是否至少绑定了一个 enabled target
|
||||||
4. OpenCode 当前实际使用的配置文件,是否就是 `ops opencode sync` 写入的那个文件
|
4. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件
|
||||||
5. 执行一次 `ops doctor`,看输出里的 `opencode config target`
|
5. 执行一次 `olpx doctor`,看输出里的 `opencode config target`
|
||||||
|
|
||||||
### 为什么 `ops doctor` 报 alias 没有 enabled target?
|
### 为什么 `olpx doctor` 报 alias 没有 enabled target?
|
||||||
|
|
||||||
因为当前实现要求:只要 alias 是 enabled,就必须至少有一个 enabled target。
|
因为当前实现要求:只要 alias 是 enabled,就必须至少有一个 enabled target。
|
||||||
|
|
||||||
@ -461,7 +461,7 @@ ops --config /path/to/config.json doctor
|
|||||||
因为 alias 里的 target 还是旧引用。需要继续执行:
|
因为 alias 里的 target 还是旧引用。需要继续执行:
|
||||||
|
|
||||||
```bash
|
```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:
|
默认是静态 key:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ops-local
|
olpx-local
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenCode 在 `provider.ops.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
|
OpenCode 在 `provider.olpx.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
|
||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|
||||||
- 默认只监听 `127.0.0.1`
|
- 默认只监听 `127.0.0.1`
|
||||||
- 上游凭据保存在本地 `ops` 配置文件中
|
- 上游凭据保存在本地 `olpx` 配置文件中
|
||||||
- 本项目当前没有做多用户或远程网络安全保证
|
- 本项目当前没有做多用户或远程网络安全保证
|
||||||
|
|
||||||
所以请把本地配置文件当成敏感文件处理。
|
所以请把本地配置文件当成敏感文件处理。
|
||||||
|
|||||||
54
README_EN.md
54
README_EN.md
@ -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
|
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 `ops` to OpenCode.
|
- Expose one custom provider `olpx` to OpenCode.
|
||||||
- Configure logical aliases (`ops/gpt-5.4`, etc.).
|
- Configure logical aliases (`olpx/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.
|
||||||
- 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, `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
|
- Once a stream has started, the upstream is locked for the rest of that
|
||||||
request — no mid-stream splicing.
|
request — no mid-stream splicing.
|
||||||
|
|
||||||
@ -17,35 +17,35 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o ops ./cmd/ops
|
go build -o olpx ./cmd/olpx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. add upstream providers
|
# 1. add upstream providers
|
||||||
ops provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-...
|
olpx 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 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
|
||||||
ops alias add --name gpt-5.4
|
olpx alias add --name gpt-5.4
|
||||||
ops alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
|
olpx 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 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
|
||||||
ops opencode sync
|
olpx opencode sync
|
||||||
|
|
||||||
# 4. run the proxy
|
# 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
|
### Import providers from an existing OpenCode config
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops provider import-opencode # reads global OpenCode config
|
olpx provider import-opencode # reads global OpenCode config
|
||||||
ops provider import-opencode --from ./examples/opencode.jsonc
|
olpx 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
|
||||||
@ -58,30 +58,30 @@ imported. Everything else is out of MVP scope.
|
|||||||
### Doctor (static)
|
### Doctor (static)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ops doctor
|
olpx doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs structural checks only — never issues real upstream requests.
|
Runs structural checks only — never issues real upstream requests.
|
||||||
|
|
||||||
## CLI reference
|
## CLI reference
|
||||||
|
|
||||||
- `ops serve` — run the proxy
|
- `olpx serve` — run the proxy
|
||||||
- `ops doctor` — validate config
|
- `olpx doctor` — validate config
|
||||||
- `ops provider {add,list,remove,import-opencode}`
|
- `olpx provider {add,list,remove,import-opencode}`
|
||||||
- `ops alias {add,list,bind,unbind,remove}`
|
- `olpx alias {add,list,bind,unbind,remove}`
|
||||||
- `ops opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
|
- `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
|
## Debug headers
|
||||||
|
|
||||||
Every proxied response includes:
|
Every proxied response includes:
|
||||||
|
|
||||||
- `X-OPS-Alias`
|
- `X-OLPX-Alias`
|
||||||
- `X-OPS-Provider`
|
- `X-OLPX-Provider`
|
||||||
- `X-OPS-Remote-Model`
|
- `X-OLPX-Remote-Model`
|
||||||
- `X-OPS-Attempt`
|
- `X-OLPX-Attempt`
|
||||||
- `X-OPS-Failover-Count`
|
- `X-OLPX-Failover-Count`
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Command ops: local alias + failover proxy for OpenCode.
|
// Command olpx: local alias + failover proxy for OpenCode.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -13,7 +13,7 @@ import (
|
|||||||
func newAliasCmd() *cobra.Command {
|
func newAliasCmd() *cobra.Command {
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
Use: "alias",
|
Use: "alias",
|
||||||
Short: "Manage logical aliases routed by ops",
|
Short: "Manage logical aliases routed by olpx",
|
||||||
}
|
}
|
||||||
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
|
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
|
||||||
return c
|
return c
|
||||||
@ -53,7 +53,7 @@ func newAliasAddCmd() *cobra.Command {
|
|||||||
return nil
|
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().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
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestProviderAddPreservesExistingFields(t *testing.T) {
|
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 = ""
|
configPath = ""
|
||||||
|
|
||||||
cfg, err := loadCfg()
|
cfg, err := loadCfg()
|
||||||
@ -56,8 +56,8 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
|
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
|
||||||
configFile := filepath.Join(t.TempDir(), "ops.json")
|
configFile := filepath.Join(t.TempDir(), "olpx.json")
|
||||||
t.Setenv("OPS_CONFIG", configFile)
|
t.Setenv("OLPX_CONFIG", configFile)
|
||||||
configPath = ""
|
configPath = ""
|
||||||
|
|
||||||
cmd := newProviderAddCmd()
|
cmd := newProviderAddCmd()
|
||||||
@ -75,7 +75,7 @@ func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAliasAddPreservesExistingFields(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 = ""
|
configPath = ""
|
||||||
|
|
||||||
cfg, err := loadCfg()
|
cfg, err := loadCfg()
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import (
|
|||||||
func newDoctorCmd() *cobra.Command {
|
func newDoctorCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "doctor",
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := loadCfg()
|
cfg, err := loadCfg()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -32,9 +32,9 @@ func newDoctorCmd() *cobra.Command {
|
|||||||
aliasNames = append(aliasNames, a.Alias)
|
aliasNames = append(aliasNames, a.Alias)
|
||||||
}
|
}
|
||||||
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.EnsureOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||||
if err := opencode.ValidateOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
||||||
issues = append(issues, fmt.Errorf("opencode provider.ops invalid: %w", err))
|
issues = append(issues, fmt.Errorf("opencode provider.olpx invalid: %w", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ok := len(issues) == 0
|
ok := len(issues) == 0
|
||||||
@ -55,7 +55,7 @@ func newDoctorCmd() *cobra.Command {
|
|||||||
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.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)
|
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@ -24,8 +24,8 @@ func newOpencodeSyncCmd() *cobra.Command {
|
|||||||
var dryRun bool
|
var dryRun bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: "Update provider.ops in the global OpenCode config to match current aliases",
|
Short: "Update provider.olpx in the global OpenCode config to match current aliases",
|
||||||
Long: `ops opencode sync writes provider.ops into the target OpenCode config.
|
Long: `olpx opencode sync writes provider.olpx 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,
|
||||||
@ -59,7 +59,7 @@ or creating opencode.jsonc if none exists. It does NOT touch the top-level
|
|||||||
aliasNames = append(aliasNames, a.Alias)
|
aliasNames = append(aliasNames, a.Alias)
|
||||||
}
|
}
|
||||||
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.EnsureOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||||
if setModel != "" {
|
if setModel != "" {
|
||||||
if raw["model"] != setModel {
|
if raw["model"] != setModel {
|
||||||
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 {
|
if err := opencode.Save(path, raw); err != nil {
|
||||||
return err
|
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 != "" {
|
if setModel != "" {
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
|
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Package cli wires the ops cobra command tree.
|
// Package cli wires the olpx cobra command tree.
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -13,21 +13,21 @@ import (
|
|||||||
// 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 ops config, with the selected path.
|
// loadCfg opens the active olpx 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 ops command.
|
// NewRootCmd builds the root olpx command.
|
||||||
func NewRootCmd(version string) *cobra.Command {
|
func NewRootCmd(version string) *cobra.Command {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "ops",
|
Use: "olpx",
|
||||||
Short: "opencode-provider-switch: local alias + failover proxy for OpenCode",
|
Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
Version: version,
|
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(newServeCmd())
|
||||||
root.AddCommand(newDoctorCmd())
|
root.AddCommand(newDoctorCmd())
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
func newServeCmd() *cobra.Command {
|
func newServeCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "serve",
|
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 {
|
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 ops JSON config file.
|
// Package config manages the local olpx JSON config file.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -43,7 +43,7 @@ type Server struct {
|
|||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config is the on-disk ops config.
|
// Config is the on-disk olpx config.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server Server `json:"server"`
|
Server Server `json:"server"`
|
||||||
Providers []Provider `json:"providers"`
|
Providers []Provider `json:"providers"`
|
||||||
@ -72,26 +72,26 @@ func Default() *Config {
|
|||||||
Server: Server{
|
Server: Server{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 9982,
|
Port: 9982,
|
||||||
APIKey: "ops-local",
|
APIKey: "olpx-local",
|
||||||
},
|
},
|
||||||
Providers: []Provider{},
|
Providers: []Provider{},
|
||||||
Aliases: []Alias{},
|
Aliases: []Alias{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.config/ops/config.json (XDG aware).
|
// DefaultPath returns ~/.config/olpx/config.json (XDG aware).
|
||||||
func DefaultPath() string {
|
func DefaultPath() string {
|
||||||
if p := os.Getenv("OPS_CONFIG"); p != "" {
|
if p := os.Getenv("OLPX_CONFIG"); 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, "ops", "config.json")
|
return filepath.Join(xdg, "olpx", "config.json")
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
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.
|
// 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
|
c.Server.Port = 9982
|
||||||
}
|
}
|
||||||
if c.Server.APIKey == "" {
|
if c.Server.APIKey == "" {
|
||||||
c.Server.APIKey = "ops-local"
|
c.Server.APIKey = "olpx-local"
|
||||||
}
|
}
|
||||||
c.path = path
|
c.path = path
|
||||||
return c, nil
|
return c, nil
|
||||||
|
|||||||
@ -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.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.
|
// detected extension on write.
|
||||||
package opencode
|
package opencode
|
||||||
|
|
||||||
@ -71,8 +71,8 @@ func Load(path string) (Raw, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save writes provider.ops back to path. Existing files are normalized to plain
|
// Save writes provider.olpx back to path. Existing files are normalized to plain
|
||||||
// JSON and only the provider.ops subtree is patched so unrelated key order stays
|
// 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
|
// 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 {
|
||||||
@ -101,7 +101,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 := patchProviderOpsDocument(original, raw)
|
patched, err := patchProviderOLPXDocument(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)
|
||||||
}
|
}
|
||||||
@ -121,8 +121,8 @@ func marshalRaw(raw Raw) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func patchProviderOpsDocument(original []byte, raw Raw) ([]byte, error) {
|
func patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
|
||||||
opsRaw, ok := opsProviderValue(raw)
|
olpxRaw, ok := olpxProviderValue(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return marshalRaw(raw)
|
return marshalRaw(raw)
|
||||||
}
|
}
|
||||||
@ -143,7 +143,7 @@ func patchProviderOpsDocument(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{"ops": opsRaw})
|
return insertObjectMember(normalized, root, "provider", map[string]any{"olpx": olpxRaw})
|
||||||
}
|
}
|
||||||
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")
|
||||||
@ -152,23 +152,23 @@ func patchProviderOpsDocument(original []byte, raw Raw) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ops := providerObj.findMember("ops")
|
olpx := providerObj.findMember("olpx")
|
||||||
if ops == nil {
|
if olpx == nil {
|
||||||
return insertObjectMember(normalized, providerObj, "ops", opsRaw)
|
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)
|
providerRaw, _ := raw["provider"].(map[string]any)
|
||||||
if providerRaw == nil {
|
if providerRaw == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
opsRaw, _ := providerRaw["ops"].(map[string]any)
|
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
|
||||||
if opsRaw == nil {
|
if olpxRaw == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return opsRaw, true
|
return olpxRaw, true
|
||||||
}
|
}
|
||||||
|
|
||||||
type objectSpan struct {
|
type objectSpan struct {
|
||||||
@ -409,11 +409,11 @@ func lineIndent(data []byte, pos int) string {
|
|||||||
return string(data[lineStart:lineEnd])
|
return string(data[lineStart:lineEnd])
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureOpsProvider updates (or creates) the provider.ops entry with the given
|
// EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given
|
||||||
// local base URL, local api key and alias set. Existing keys on provider.ops
|
// 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
|
// are preserved unless they conflict with the sync intent. Returns true if the
|
||||||
// file would actually change.
|
// 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
|
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"
|
||||||
@ -425,22 +425,22 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
|
|||||||
raw["provider"] = provRaw
|
raw["provider"] = provRaw
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
opsRaw, _ := provRaw["ops"].(map[string]any)
|
olpxRaw, _ := provRaw["olpx"].(map[string]any)
|
||||||
if opsRaw == nil {
|
if olpxRaw == nil {
|
||||||
opsRaw = map[string]any{}
|
olpxRaw = map[string]any{}
|
||||||
provRaw["ops"] = opsRaw
|
provRaw["olpx"] = olpxRaw
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if setIfDiff(opsRaw, "npm", "@ai-sdk/openai") {
|
if setIfDiff(olpxRaw, "npm", "@ai-sdk/openai") {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if setIfDiff(opsRaw, "name", "OPS") {
|
if setIfDiff(olpxRaw, "name", "OpenCode LocalProxy CLI") {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
opts, _ := opsRaw["options"].(map[string]any)
|
opts, _ := olpxRaw["options"].(map[string]any)
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
opts = map[string]any{}
|
opts = map[string]any{}
|
||||||
opsRaw["options"] = opts
|
olpxRaw["options"] = opts
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if setIfDiff(opts, "baseURL", baseURL) {
|
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
|
// Build models map from alias list. Preserve any existing per-model extras
|
||||||
// if the alias key matches; drop aliases removed locally.
|
// 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{}
|
newModels := map[string]any{}
|
||||||
aliasSet := map[string]bool{}
|
aliasSet := map[string]bool{}
|
||||||
for _, a := range aliases {
|
for _, a := range aliases {
|
||||||
@ -477,43 +477,43 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !mapsEqualShallow(existingModels, newModels) {
|
if !mapsEqualShallow(existingModels, newModels) {
|
||||||
opsRaw["models"] = newModels
|
olpxRaw["models"] = newModels
|
||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateOpsProvider checks that provider.ops matches the MVP sync contract.
|
// ValidateOLPXProvider checks that provider.olpx matches the MVP sync contract.
|
||||||
func ValidateOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
|
func ValidateOLPXProvider(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")
|
||||||
}
|
}
|
||||||
opsRaw, _ := provRaw["ops"].(map[string]any)
|
olpxRaw, _ := provRaw["olpx"].(map[string]any)
|
||||||
if opsRaw == nil {
|
if olpxRaw == nil {
|
||||||
return fmt.Errorf("missing provider.ops")
|
return fmt.Errorf("missing provider.olpx")
|
||||||
}
|
}
|
||||||
if npm, _ := opsRaw["npm"].(string); npm != "@ai-sdk/openai" {
|
if npm, _ := olpxRaw["npm"].(string); npm != "@ai-sdk/openai" {
|
||||||
return fmt.Errorf("provider.ops.npm must be @ai-sdk/openai")
|
return fmt.Errorf("provider.olpx.npm must be @ai-sdk/openai")
|
||||||
}
|
}
|
||||||
if name, _ := opsRaw["name"].(string); name != "OPS" {
|
if name, _ := olpxRaw["name"].(string); name != "OpenCode LocalProxy CLI" {
|
||||||
return fmt.Errorf("provider.ops.name must be OPS")
|
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 {
|
if opts == nil {
|
||||||
return fmt.Errorf("provider.ops.options missing")
|
return fmt.Errorf("provider.olpx.options missing")
|
||||||
}
|
}
|
||||||
if got, _ := opts["baseURL"].(string); got != baseURL {
|
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 {
|
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 {
|
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 {
|
if models == nil {
|
||||||
return fmt.Errorf("provider.ops.models missing")
|
return fmt.Errorf("provider.olpx.models missing")
|
||||||
}
|
}
|
||||||
expected := append([]string(nil), aliases...)
|
expected := append([]string(nil), aliases...)
|
||||||
sort.Strings(expected)
|
sort.Strings(expected)
|
||||||
@ -521,20 +521,20 @@ func ValidateOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) erro
|
|||||||
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.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 {
|
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)
|
actual = append(actual, alias)
|
||||||
}
|
}
|
||||||
sort.Strings(actual)
|
sort.Strings(actual)
|
||||||
if len(actual) != len(expected) {
|
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 {
|
for i := range actual {
|
||||||
if actual[i] != expected[i] {
|
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
|
return nil
|
||||||
@ -560,13 +560,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 `ops` id itself is
|
// declare baseURL and an apiKey-compatible setting. The `olpx` 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 == "ops" {
|
if id == "olpx" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m, ok := v.(map[string]any)
|
m, ok := v.(map[string]any)
|
||||||
|
|||||||
@ -64,76 +64,76 @@ func TestResolveGlobalConfigPathPrecedence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateOpsProvider(t *testing.T) {
|
func TestValidateOLPXProvider(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 := "ops-local"
|
apiKey := "olpx-local"
|
||||||
EnsureOpsProvider(raw, baseURL, apiKey, aliases)
|
EnsureOLPXProvider(raw, baseURL, apiKey, aliases)
|
||||||
|
|
||||||
providerRaw, _ := raw["provider"].(map[string]any)
|
providerRaw, _ := raw["provider"].(map[string]any)
|
||||||
opsRaw, _ := providerRaw["ops"].(map[string]any)
|
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
|
||||||
opts, _ := opsRaw["options"].(map[string]any)
|
opts, _ := olpxRaw["options"].(map[string]any)
|
||||||
if got, ok := opts["setCacheKey"].(bool); !ok || !got {
|
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 {
|
if err := ValidateOLPXProvider(raw, baseURL, apiKey, aliases); err != nil {
|
||||||
t.Fatalf("ValidateOpsProvider() unexpected error: %v", err)
|
t.Fatalf("ValidateOLPXProvider() unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderSaveDataReplacesExistingProviderOpsOnly(t *testing.T) {
|
func TestRenderSaveDataReplacesExistingProviderOLPXOnly(t *testing.T) {
|
||||||
raw := Raw{
|
raw := Raw{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"model": "ops/gpt-5.4",
|
"model": "olpx/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"},
|
||||||
"ops": map[string]any{
|
"olpx": map[string]any{
|
||||||
"npm": "@ai-sdk/openai",
|
"npm": "@ai-sdk/openai",
|
||||||
"name": "OPS",
|
"name": "OpenCode LocalProxy CLI",
|
||||||
"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": "ops-local",
|
"apiKey": "olpx-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": "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 {
|
if err != nil {
|
||||||
t.Fatalf("patchProviderOpsDocument() error: %v", err)
|
t.Fatalf("patchProviderOLPXDocument() 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"`, `"ops"`, `"openai"`})
|
assertStringOrder(t, string(got), []string{`"anthropic"`, `"olpx"`, `"openai"`})
|
||||||
if strings.Contains(string(got), `"npm": "old"`) {
|
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
|
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 := ValidateOpsProvider(saved, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}); err != nil {
|
if err := ValidateOLPXProvider(saved, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
|
||||||
t.Fatalf("ValidateOpsProvider(saved) error: %v", err)
|
t.Fatalf("ValidateOLPXProvider(saved) error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderSaveDataInsertsOpsWithoutReorderingProviderKeys(t *testing.T) {
|
func TestRenderSaveDataInsertsOLPXWithoutReorderingProviderKeys(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"},
|
||||||
"ops": map[string]any{
|
"olpx": map[string]any{
|
||||||
"npm": "@ai-sdk/openai",
|
"npm": "@ai-sdk/openai",
|
||||||
"name": "OPS",
|
"name": "OpenCode LocalProxy CLI",
|
||||||
"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": "ops-local",
|
"apiKey": "olpx-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"}},
|
||||||
@ -141,38 +141,38 @@ func TestRenderSaveDataInsertsOpsWithoutReorderingProviderKeys(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\": \"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 {
|
if err != nil {
|
||||||
t.Fatalf("patchProviderOpsDocument() error: %v", err)
|
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||||
}
|
}
|
||||||
assertValidJSON(t, got)
|
assertValidJSON(t, got)
|
||||||
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"ops"`})
|
assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"olpx"`})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) {
|
func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) {
|
||||||
raw := Raw{
|
raw := Raw{
|
||||||
"model": "ops/gpt-5.4",
|
"model": "olpx/gpt-5.4",
|
||||||
"provider": map[string]any{
|
"provider": map[string]any{
|
||||||
"ops": map[string]any{
|
"olpx": map[string]any{
|
||||||
"npm": "@ai-sdk/openai",
|
"npm": "@ai-sdk/openai",
|
||||||
"name": "OPS",
|
"name": "OpenCode LocalProxy CLI",
|
||||||
"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": "ops-local",
|
"apiKey": "olpx-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": "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 {
|
if err != nil {
|
||||||
t.Fatalf("patchProviderOpsDocument() error: %v", err)
|
t.Fatalf("patchProviderOLPXDocument() 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"`})
|
||||||
@ -181,12 +181,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{
|
||||||
"ops": map[string]any{
|
"olpx": map[string]any{
|
||||||
"npm": "@ai-sdk/openai",
|
"npm": "@ai-sdk/openai",
|
||||||
"name": "OPS",
|
"name": "OpenCode LocalProxy CLI",
|
||||||
"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": "ops-local",
|
"apiKey": "olpx-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"}},
|
||||||
@ -195,9 +195,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 := patchProviderOpsDocument(original, raw)
|
got, err := patchProviderOLPXDocument(original, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("patchProviderOpsDocument() error: %v", err)
|
t.Fatalf("patchProviderOLPXDocument() error: %v", err)
|
||||||
}
|
}
|
||||||
assertValidJSON(t, got)
|
assertValidJSON(t, got)
|
||||||
if bytes.Contains(got, []byte("// comment")) {
|
if bytes.Contains(got, []byte("// comment")) {
|
||||||
@ -208,51 +208,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{
|
||||||
"ops": map[string]any{
|
"olpx": map[string]any{
|
||||||
"npm": "@ai-sdk/openai",
|
"npm": "@ai-sdk/openai",
|
||||||
"name": "OPS",
|
"name": "OpenCode LocalProxy CLI",
|
||||||
"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": "ops-local",
|
"apiKey": "olpx-local",
|
||||||
"setCacheKey": true,
|
"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")
|
t.Fatal("expected invalid json/jsonc error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) {
|
func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) {
|
||||||
raw := Raw{}
|
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")
|
t.Fatal("expected provider object error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) {
|
func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) {
|
||||||
raw := Raw{}
|
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")
|
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")
|
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\": \"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)
|
t.Fatalf("write seed config: %v", err)
|
||||||
}
|
}
|
||||||
raw := Raw{}
|
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 {
|
if err := Save(path, raw); err != nil {
|
||||||
t.Fatalf("Save() error: %v", err)
|
t.Fatalf("Save() error: %v", err)
|
||||||
@ -266,8 +266,8 @@ 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 := ValidateOpsProvider(loaded, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}); err != nil {
|
if err := ValidateOLPXProvider(loaded, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil {
|
||||||
t.Fatalf("ValidateOpsProvider(loaded) error: %v", err)
|
t.Fatalf("ValidateOLPXProvider(loaded) error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
// ops aliases and forwards requests to upstream providers with deterministic
|
// olpx aliases and forwards requests to upstream providers with deterministic
|
||||||
// pre-first-byte failover.
|
// pre-first-byte failover.
|
||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ type openAIError struct {
|
|||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the local ops HTTP proxy.
|
// Server is the local olpx 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(), "[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{
|
data = append(data, map[string]any{
|
||||||
"id": a.Alias,
|
"id": a.Alias,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"owned_by": "ops",
|
"owned_by": "olpx",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@ -379,7 +379,7 @@ func errorTypeForStatus(status int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAliasName(model string) string {
|
func normalizeAliasName(model string) string {
|
||||||
const prefix = "ops/"
|
const prefix = "olpx/"
|
||||||
if strings.HasPrefix(model, prefix) {
|
if strings.HasPrefix(model, prefix) {
|
||||||
trimmed := strings.TrimPrefix(model, prefix)
|
trimmed := strings.TrimPrefix(model, prefix)
|
||||||
if trimmed != "" {
|
if trimmed != "" {
|
||||||
@ -389,14 +389,14 @@ func normalizeAliasName(model string) string {
|
|||||||
return model
|
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) {
|
func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("X-OPS-Alias", alias)
|
h.Set("X-OLPX-Alias", alias)
|
||||||
h.Set("X-OPS-Provider", provider)
|
h.Set("X-OLPX-Provider", provider)
|
||||||
h.Set("X-OPS-Remote-Model", remoteModel)
|
h.Set("X-OLPX-Remote-Model", remoteModel)
|
||||||
h.Set("X-OPS-Attempt", fmt.Sprintf("%d", attempt))
|
h.Set("X-OLPX-Attempt", fmt.Sprintf("%d", attempt))
|
||||||
h.Set("X-OPS-Failover-Count", fmt.Sprintf("%d", failoverCount))
|
h.Set("X-OLPX-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.
|
||||||
|
|||||||
@ -18,10 +18,10 @@ func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
srv := New(&config.Config{
|
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 := 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")
|
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: "ops-local"},
|
Server: config.Server{APIKey: "olpx-local"},
|
||||||
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":"ops/gpt-5.4","stream":true}`))
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"olpx/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")
|
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-OPS-Attempt"); got != "2" {
|
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" {
|
||||||
t.Fatalf("X-OPS-Attempt = %q, want 2", got)
|
t.Fatalf("X-OLPX-Attempt = %q, want 2", got)
|
||||||
}
|
}
|
||||||
if got := rr.Header().Get("X-OPS-Failover-Count"); got != "1" {
|
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" {
|
||||||
t.Fatalf("X-OPS-Failover-Count = %q, want 1", got)
|
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got)
|
||||||
}
|
}
|
||||||
if got := rr.Header().Get("X-OPS-Provider"); got != "p2" {
|
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
||||||
t.Fatalf("X-OPS-Provider = %q, want p2", got)
|
t.Fatalf("X-OLPX-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: "ops-local"},
|
Server: config.Server{APIKey: "olpx-local"},
|
||||||
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 ops-local")
|
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||||
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-OPS-Provider"); got != "p1" {
|
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" {
|
||||||
t.Fatalf("X-OPS-Provider = %q, want p1", got)
|
t.Fatalf("X-OLPX-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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user