diff --git a/README.ko.md b/README.ko.md index c4ccd4b9776..014f7964f34 100644 --- a/README.ko.md +++ b/README.ko.md @@ -112,6 +112,7 @@ codex -m "xai/grok-4" "이 PR을 리뷰해 줘" |---|---|---| | OpenAI (ChatGPT 로그인) | `openai-responses` | forward (키 불필요) | | OpenAI (API 키) | `openai-responses` | key | +| Umans AI Coding Plan | `anthropic` | key | | Anthropic Claude | `anthropic` | oauth / key | | xAI Grok | `openai-chat` | oauth / key | | Kimi (Moonshot) | `openai-chat` | oauth / key | diff --git a/README.md b/README.md index 7a91eb2eb90..26709d99e61 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Routed models also appear in the **Codex App** model picker with per-model reaso |---|---|---| | OpenAI (ChatGPT login) | `openai-responses` | forward (no key) | | OpenAI (API key) | `openai-responses` | key | +| Umans AI Coding Plan | `anthropic` | key | | Anthropic Claude | `anthropic` | oauth / key | | xAI Grok | `openai-chat` | oauth / key | | Kimi (Moonshot) | `openai-chat` | oauth / key | @@ -243,6 +244,22 @@ Local models work too. Point opencodex at any OpenAI-compatible server running o WebSocket transport is off by default. Set `"websockets": true` only if you want Codex to advertise and use the Responses WebSocket path instead of HTTP/SSE. +opencodex leaves existing Codex resume history untouched by default. This avoids changing Codex's +local thread index just because the proxy started, but Codex App may hide old OpenAI-backed project +threads and opencodex-created `exec` threads while `opencodex` is the active provider. If you want +those chats to appear while the proxy is active, enable the reversible compatibility remap with +`"syncResumeHistory": true`. opencodex records the original provider/source metadata in +`~/.opencodex/codex-history-backup.json`. `ocx stop` / `ocx restore` restores backed-up OpenAI rows +to OpenAI, and ejects any remaining opencodex user threads to OpenAI as well so native Codex does not +try to resume a thread whose provider no longer exists in `config.toml`. + +If you tested an older development build where `syncResumeHistory` already remapped history before +backup support existed, you can also run the explicit recovery command: + +```bash +ocx recover-history --legacy-openai +``` + See the **[Configuration reference](https://lidge-jun.github.io/opencodex/reference/configuration/)** for every field. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 28a4c5642d7..14cde395a41 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -106,6 +106,7 @@ codex -m "deepseek/deepseek-r1" "分析这个性能瓶颈" |---|---|---| | OpenAI(ChatGPT 登录) | `openai-responses` | 转发(无需 key) | | OpenAI(API key) | `openai-responses` | key | +| Umans AI Coding Plan | `anthropic` | key | | Anthropic Claude | `anthropic` | oauth / key | | xAI Grok | `openai-chat` | oauth / key | | Kimi(Moonshot) | `openai-chat` | oauth / key | diff --git a/devlog/220_codex-app-history-visibility/00_plan.md b/devlog/220_codex-app-history-visibility/00_plan.md new file mode 100644 index 00000000000..f80c82cb902 --- /dev/null +++ b/devlog/220_codex-app-history-visibility/00_plan.md @@ -0,0 +1,115 @@ +# 220 Codex App history visibility + +## Goal + +Investigate and fix the Codex App project-sidebar history visibility regression around `ocx start` / `ocx stop`, issue #11, and PR #13. + +The fix must preserve user data by default, document the exact reproduction, explain the Codex App filtering semantics we can infer from `codex-rs` and local state, and avoid broad irreversible mutation of the user's real `~/.codex/state_5.sqlite`. + +## User reproduction + +The local `ocx` binary is the development checkout: + +- `/Users/jun/.local/bin/ocx` +- `/Users/jun/Developer/new/700_projects/opencodex/dist/bin/ocx` +- `/Users/jun/Developer/new/700_projects/opencodex/src/cli.ts` + +Observed behavior: + +1. `ocx start` makes all Codex App project conversations disappear from the sidebar, including old OpenAI conversations and conversations created while opencodex was active. +2. `ocx stop` makes old OpenAI-side conversations visible again. +3. Conversations created while opencodex was active remain invisible after `ocx stop`. + +## Current evidence + +Local config evidence: + +- `/Users/jun/.opencodex/config.json` currently has no `syncResumeHistory` key, so PR #13's default is "do not rewrite resume history". +- `~/.codex/config.toml` can be in native mode after `ocx stop`, with no root `model_provider = "opencodex"`. + +Local Codex state evidence from `~/.codex/state_5.sqlite`: + +| Row set | model_provider | source | Count / effect | +| --- | --- | --- | --- | +| App/CLI resumable rows | `openai` | `cli`, `vscode` | Visible when native OpenAI provider is active | +| opencodex-created project rows | `opencodex` | `exec` | Not visible in Codex App project sidebar | +| opencodex `cli`/`vscode` rows | `opencodex` | `cli`, `vscode` | None found in current local state | + +Working hypothesis: + +- Codex App visibility is not controlled by provider alone. +- It likely filters by both `threads.model_provider` and a resumable/source classification. +- Old OpenAI rows disappear during `ocx start` because the active/root provider becomes `opencodex`. +- opencodex-created rows stay hidden because they are mostly `source = 'exec'`, not `cli` or `vscode`. + +## Current PR #13 behavior + +PR #13 is already merged locally on `dev` for testing. + +It changes the default from automatic resume-history rewriting to explicit opt-in: + +- Default: leave history unchanged. +- `syncResumeHistory: true`: remap resumable OpenAI rows to opencodex during `ocx start`. +- `ocx stop`: remap opencodex rows back to OpenAI using the existing restoration path. + +Limitation: + +- #13 is a safety improvement, not the full #11 fix. +- It only addresses the provider-label side of the problem. +- It does not make opencodex-created `source = 'exec'` rows visible in Codex App. + +## Plan + +### P / A: document and audit + +- Create this devlog file as the durable issue record. +- Correctly notify GitHub issue #11 and PR #13 that investigation is underway with the refined reproduction and current hypothesis. +- Run a read-only audit worker against the plan and the local code anchors before editing behavior. + +### B: investigate and implement + +- Inspect local `codex-rs` / Codex App sources or installed artifacts for thread filtering semantics around `model_provider`, `source`, `thread_source`, `cwd`, `archived`, and `has_user_event`. +- Re-check the local SQLite schema and representative rows without mutating the real DB. +- Design the smallest opencodex-side compatibility fix that can make old OpenAI rows and opencodex-created rows visible without unsafe broad mutation. +- Preserve reversibility. If metadata must be changed, record original values before changing them and restore only rows opencodex touched. + +### C: verify + +- Add unit tests against temporary SQLite fixtures only. +- Run targeted tests first, then full `bun test tests`. +- Run `bun run typecheck`. +- Verify no test or script mutates the real `~/.codex/state_5.sqlite`. + +### D: report + +- Summarize the root cause in plain Korean. +- List exact files changed. +- Include verification commands and results. +- Note remaining Codex App upstream limitation if any behavior can only be fully fixed in `codex-rs`. + +## Likely files + +Likely code files: + +- `/Users/jun/Developer/new/700_projects/opencodex/src/codex-history-provider.ts` +- `/Users/jun/Developer/new/700_projects/opencodex/src/codex-inject.ts` +- `/Users/jun/Developer/new/700_projects/opencodex/src/types.ts` + +Likely tests: + +- `/Users/jun/Developer/new/700_projects/opencodex/tests/codex-history-provider.test.ts` +- `/Users/jun/Developer/new/700_projects/opencodex/tests/codex-inject.test.ts` + +Likely docs: + +- `/Users/jun/Developer/new/700_projects/opencodex/README.md` +- `/Users/jun/Developer/new/700_projects/opencodex/docs-site/src/content/docs/reference/configuration.md` +- `/Users/jun/Developer/new/700_projects/opencodex/docs-site/src/content/docs/ko/reference/configuration.md` +- `/Users/jun/Developer/new/700_projects/opencodex/docs-site/src/content/docs/zh/reference/configuration.md` + +## Safety rules + +- Do not mutate the user's real Codex DB while testing. +- Do not broaden `ocx start` default behavior into silent history rewriting without an explicit config or user action. +- Do not let `ocx stop` broadly rewrite all `opencodex` rows to `openai` if the implementation starts producing genuinely opencodex-owned visible rows. +- Prefer reversible metadata backup over pattern-based rollback. diff --git a/devlog/220_codex-app-history-visibility/01_codex-rs-findings.md b/devlog/220_codex-app-history-visibility/01_codex-rs-findings.md new file mode 100644 index 00000000000..305c7c28a95 --- /dev/null +++ b/devlog/220_codex-app-history-visibility/01_codex-rs-findings.md @@ -0,0 +1,128 @@ +# Codex App / codex-rs findings + +## Summary + +The sidebar disappearance is caused by two independent filters in Codex App / app-server: + +1. Provider filter: when `model_providers` is omitted, app-server defaults to the active configured provider. +2. Source filter: when `source_kinds` is omitted or empty, app-server defaults to `INTERACTIVE_SESSION_SOURCES`. + +In the local codex-rs checkout at `/Users/jun/Developer/codex/codex-cli/codex-rs`, `INTERACTIVE_SESSION_SOURCES` is: + +```text +cli +vscode +custom atlas +custom chatgpt +``` + +It does not include `exec`. + +## Source anchors + +codex-rs source anchors: + +- `/Users/jun/Developer/codex/codex-cli/codex-rs/rollout/src/lib.rs` + - `INTERACTIVE_SESSION_SOURCES` includes `Cli`, `VSCode`, `Custom("atlas")`, `Custom("chatgpt")`. +- `/Users/jun/Developer/codex/codex-cli/codex-rs/app-server/src/filters.rs` + - `compute_source_filters(None)` returns `INTERACTIVE_SESSION_SOURCES`. + - `compute_source_filters(Some(Vec::new()))` also returns `INTERACTIVE_SESSION_SOURCES`. + - `ThreadSourceKind::Exec` requires an explicit source filter. +- `/Users/jun/Developer/codex/codex-cli/codex-rs/app-server/src/request_processors/thread_processor.rs` + - `model_providers: None` becomes `Some(vec![self.config.model_provider_id.clone()])`. + - `source_kinds` flows through `compute_source_filters()`. + - Those filters are passed to `thread_store.list_threads()`. +- `/Users/jun/Developer/codex/codex-cli/codex-rs/state/src/runtime/threads.rs` + - SQL filter applies `threads.archived = 0`, `threads.preview <> ''`, optional `threads.source IN (...)`, optional `threads.model_provider IN (...)`, and optional `threads.cwd IN (...)`. + +## Local DB evidence + +Read-only query against `/Users/jun/.codex/state_5.sqlite` for project cwd `/Users/jun/Developer/new/700_projects/opencodex`: + +| model_provider | source | count | +| --- | --- | ---: | +| `openai` | `cli` | 7 | +| `openai` | `exec` | 2 | +| `opencodex` | `exec` | 43 | +| `opencodex` | subagent thread-spawn JSON | 2 | + +Default Codex App list while opencodex is active: + +```sql +WHERE archived = 0 + AND preview <> '' + AND source IN ('cli', 'vscode', 'atlas', 'chatgpt') + AND model_provider = 'opencodex' +``` + +That returns zero rows locally because opencodex-created project rows are `source = 'exec'`. + +## Upstream patch direction + +The cleaner upstream fix would be in Codex App / codex-rs: + +- either request `sourceKinds` including `exec` for the project sidebar, +- or make the project sidebar intentionally provider/source agnostic when the user is browsing project history, +- or expose a UI affordance for source filtering. + +opencodex cannot change Codex App's `thread/list` request payload. The opencodex-side fix therefore must be an explicit compatibility mode that temporarily adjusts local metadata and restores it later. + +## opencodex fix direction + +For `syncResumeHistory: true`: + +- backup original thread metadata into `~/.opencodex/codex-history-backup.json`; +- remap old OpenAI `cli`/`vscode` rows to `model_provider = 'opencodex'`; +- promote opencodex-created user `exec` rows to `source = 'cli'`; +- update the rollout JSONL first `session_meta` line consistently so Codex's rollout scanner does not repair the DB back to the hidden state; +- on `ocx stop` / `ocx restore`, restore only rows recorded in the backup manifest. + +Default remains unchanged: no history mutation unless the user explicitly enables `syncResumeHistory`. + +## Legacy PR #13 upgrade edge + +There is one unsafe-to-automate edge case: + +1. A user enabled `syncResumeHistory: true` on a development build before backup support existed. +2. That build remapped old `openai` interactive rows to `opencodex`. +3. The user upgrades while those rows are still remapped. +4. The new backup manifest does not exist, so `ocx stop` cannot know which `opencodex` interactive rows were originally OpenAI rows. + +The fix must not silently rewrite all `opencodex` `cli`/`vscode` rows to `openai`, because future or app-created rows can legitimately be opencodex-owned. Instead: + +- normal `ocx stop` detects and reports ambiguous unbacked rows; +- it leaves them unchanged by default; +- `ocx recover-history --legacy-openai` is the explicit manual recovery path for users who know those rows came from the old remap. + +## 2026-06-22 correction: native restore cannot leave opencodex providers behind + +Live local testing showed that the conservative "leave unbacked opencodex rows unchanged" approach +breaks Codex App after `ocx stop`: + +```text +Codex can't load config.toml, so this thread can't resume. +Fix config.toml: Model provider `opencodex` not found. +``` + +The root cause is straightforward: `ocx stop` removes `[model_providers.opencodex]` from +`~/.codex/config.toml`. Any remaining `threads.model_provider = 'opencodex'` row can therefore point +Codex App at a provider id that no longer exists. This applies not only to legacy `cli`/`vscode` rows, +but also to opencodex-created `exec` rows and subagent rows if the user resumes them directly. + +Revised opencodex invariant: + +- while opencodex is active, `syncResumeHistory: true` may remap/promote history so the App sidebar is visible; +- after native restore (`ocx stop`, `ocx restore`, uninstall), no user thread should be left with + `model_provider = 'opencodex'` unless the Codex config still contains that provider; +- backed-up OpenAI rows restore to OpenAI; +- opencodex-owned user rows are ejected to `openai`, and `exec` source is promoted to `cli`, so native + Codex can list/resume them after the proxy provider has been removed; +- root `model = "provider/model"` values are also stripped on native restore because provider-prefixed + routed model ids are invalid without the opencodex provider/catalog. + +Local repair evidence after the correction: + +- `ocx recover-history --legacy-openai` recovered 218 user thread rows to `openai`; +- `ocx stop` left zero user rows with `model_provider = 'opencodex'`; +- `~/.codex/config.toml` no longer contains `[model_providers.opencodex]`, root + `model_provider = "opencodex"`, or root `model = "provider/model"`. diff --git a/devlog/230_umans-wire-protocol/00_research.md b/devlog/230_umans-wire-protocol/00_research.md new file mode 100644 index 00000000000..f9187a5c0d4 --- /dev/null +++ b/devlog/230_umans-wire-protocol/00_research.md @@ -0,0 +1,320 @@ +# 230 Umans wire protocol and custom adapter research + +## Goal + +Investigate whether issue #18 can be explained by routing Umans models through the wrong wire +adapter, and decide whether opencodex needs a custom-provider path for Anthropic Messages-style +providers in addition to the existing OpenAI Chat Completions path. + +Issue anchor: + +- https://github.com/lidge-jun/opencodex/issues/18 + +## External evidence + +### Umans official docs + +Source: https://app.umans.ai/offers/code/docs + +Findings: + +- Umans Code exposes an Anthropic-compatible endpoint: + - `POST https://api.code.umans.ai/v1/messages` + - Auth header: `x-api-key` + - Requires `anthropic-version: 2023-06-01` + - Streamed responses use Anthropic Messages SSE. +- Umans Code also exposes an OpenAI-compatible endpoint: + - `POST https://api.code.umans.ai/v1/chat/completions` + - Auth header: `Authorization: Bearer ...` + - Streamed responses use Chat Completions-style chunks. +- Umans recommends `umans-kimi-k2.7` for hard coding tasks. Kimi K2.7-Code always reasons before + answering, so reasoning output is expected. +- Umans documents reasoning output by protocol: + - `/v1/messages`: `thinking` content blocks / `thinking_delta` stream events. + - `/v1/chat/completions`: `reasoning_content` on messages and streamed deltas. +- Umans documents Claude Code manual configuration with: + - `ANTHROPIC_BASE_URL=https://api.code.umans.ai` + - `ANTHROPIC_AUTH_TOKEN=sk-your-umans-api-key` + +### models.dev provider metadata + +Sources: + +- https://models.dev/providers/umans-ai/ +- https://models.dev/providers/umans-ai-coding-plan/ +- https://models.dev/api.json + +Findings: + +- Both `Umans AI` and `Umans AI Coding Plan` are listed as `@ai-sdk/openai-compatible`. +- API base is `https://api.code.umans.ai/v1`. +- `umans-kimi-k2.7`: + - family: `kimi-k2` + - reasoning: true + - reasoning_options: empty array + - tool_call: true + - structured_output: true + - temperature: false + - interleaved reasoning field: `reasoning_content` + - regular plan output limit: 32,768 + - coding plan output limit: 262,144 +- `umans-coder` currently routes to Kimi K2.7-Code and has the same Kimi-like constraints. +- `umans-glm-5.2` supports reasoning effort values `high` and `max`. +- `umans-flash` / Qwen-like models support `low`, `medium`, `high` efforts. + +### Related ecosystem signal + +Source: https://newreleases.io/project/github/can1357/oh-my-pi/release/v16.0.1 + +Findings: + +- Another coding-agent stack recently added Umans AI Coding Plan API-key login support. +- The same release fixed OpenAI-compatible Chat Completions streams whose tool arguments arrive as + object-shaped fragments and need deep-merge behavior instead of naive replacement. This is not + direct evidence that Umans emits that exact shape, but it is a useful warning that modern + OpenAI-compatible coding gateways do not always behave like simple scalar-delta streams. + +## opencodex local evidence + +### Router + +File: `/Users/jun/Developer/new/700_projects/opencodex/src/router.ts` + +Routing behavior: + +- `umans/umans-kimi-k2.7` will use provider `umans` only if `config.providers.umans` exists. +- The upstream model id becomes `umans-kimi-k2.7`. +- The provider's configured `adapter` controls the wire protocol. + +Local config check during this investigation: + +```json +{ + "defaultProvider": "opencode-go", + "umans": null +} +``` + +So the reporter's `umans` provider is not present in this local machine's +`~/.opencodex/config.json`; conclusions about its exact adapter must be verified against the +reporter's config. + +### OpenAI Chat adapter + +File: `/Users/jun/Developer/new/700_projects/opencodex/src/adapters/openai-chat.ts` + +Behavior: + +- Always sends to `${provider.baseUrl}/chat/completions`. +- Sends `max_tokens` for output cap. +- Sends `reasoning_effort` if `mapReasoningEffort(...)` returns a value. +- Sends `temperature`, `top_p`, and penalties unless the provider/model is listed in the relevant + no-parameter config arrays. +- Streams only scalar `choices[0].delta.content` and `choices[0].delta.reasoning_content` as + append-only deltas. +- Tracks one current tool call id/name, not a full index-keyed map of parallel or interleaved tool + call chunks. + +Issue #18 implication: + +- If Umans Chat Completions emits cumulative or overlapping content snapshots rather than strict + append-only deltas, opencodex will append them and live output can duplicate partial text. +- If Umans emits tool-call arguments as object fragments, cumulative snapshots, or id-less indexed + chunks, the current parser can lose or corrupt tool-call state. That can explain commentary + streaming successfully but the turn stalling before Codex receives a file-change/tool event. +- If a custom `umans` provider lacks Kimi model constraints, opencodex can send unsupported or + poorly matched params (`temperature`, `top_p`, `reasoning_effort`, `tool_choice`) to + Kimi-family models. + +### Anthropic Messages adapter + +File: `/Users/jun/Developer/new/700_projects/opencodex/src/adapters/anthropic.ts` + +Behavior: + +- Sends to `${baseUrlWithoutV1}/v1/messages`. +- For API-key providers, uses `x-api-key`. +- Sets `anthropic-version: 2023-06-01`. +- Maps Codex reasoning to Anthropic `thinking`. +- Parses `thinking_delta`, `text_delta`, and `input_json_delta` into opencodex `AdapterEvent`s. + +Issue #18 implication: + +- Since Umans officially implements `/v1/messages`, opencodex can already target the Messages path + by configuring: + +```json +{ + "providers": { + "umans": { + "adapter": "anthropic", + "baseUrl": "https://api.code.umans.ai", + "apiKey": "sk-...", + "defaultModel": "umans-coder", + "models": ["umans-coder", "umans-kimi-k2.7", "umans-glm-5.2", "umans-flash"] + } + } +} +``` + +- This route may be safer for Claude/Codex-style tool and thinking semantics than the Chat + Completions route, because Umans' own docs present it as Anthropic-compatible and Claude Code + setup uses the Anthropic base/auth variables. + +### Dashboard / custom provider surface + +Files: + +- `/Users/jun/Developer/new/700_projects/opencodex/gui/src/components/AddProviderModal.tsx` +- `/Users/jun/Developer/new/700_projects/opencodex/src/providers/derive.ts` +- `/Users/jun/Developer/new/700_projects/opencodex/src/server.ts` + +Current behavior: + +- The custom provider preset defaults to `adapter: "openai-chat"`. +- The Add Provider modal already lets a user choose: + - `openai-responses` + - `openai-chat` + - `anthropic` + - `google` + - `azure-openai` +- `/api/providers` accepts any `provider.adapter` and `provider.baseUrl`; it then calls + `enrichProviderFromCatalog(name, prov)`. + +Gap: + +- The UI does not teach users that providers such as Umans may have two valid protocols, and that + the Messages path may be safer for coding-agent/tool-call stability. +- There is no first-class Umans preset in `PROVIDER_REGISTRY`, so users are likely to add it via + Custom and leave the default `openai-chat` adapter. + +## Working hypotheses for issue #18 + +### H1: wrong protocol selection for Umans custom provider + +Evidence: + +- Custom providers default to `openai-chat`. +- Umans offers both OpenAI-compatible and Anthropic-compatible endpoints. +- Claude Code setup in Umans docs uses Anthropic-style base URL and auth token. +- Issue #18 happens in coding-agent turns where tool/file-change semantics matter. + +Risk: + +- If the reporter's `umans` provider uses `openai-chat`, opencodex goes through + `/v1/chat/completions`. +- That may still be officially supported, but it may be the less stable path for Codex's + Responses-to-tool-call bridge. + +Falsification: + +- Capture the reporter's `~/.opencodex/config.json` provider entry with secrets redacted. +- Run the same prompt with: + - `adapter: "openai-chat", baseUrl: "https://api.code.umans.ai/v1"` + - `adapter: "anthropic", baseUrl: "https://api.code.umans.ai"` +- If duplication/stall disappears only on `anthropic`, protocol selection is implicated. + +### H2: OpenAI-compatible stream is not strict scalar-delta compatible + +Evidence: + +- Issue #18 live text examples look like append duplication. +- `openai-chat.ts` assumes every `delta.content` / `delta.reasoning_content` string is append-only. +- Related ecosystem release notes warn about object-shaped tool argument fragments in + OpenAI-compatible streams. + +Falsification: + +- Record raw upstream SSE for a short Umans stream. +- Check whether `delta.content`, `delta.reasoning_content`, or `tool_calls[].function.arguments` + are append-only strings or cumulative/object fragments. + +### H3: missing model capability flags for Kimi-family Umans models + +Evidence: + +- models.dev marks `umans-kimi-k2.7` and `umans-coder` as `temperature: false` and + `reasoning_options: []`. +- Existing Kimi registry entries in opencodex have explicit no-temperature/no-top-p/no-penalty and + auto-tool-choice-only settings. +- A custom Umans provider will not inherit those Kimi constraints unless catalog enrichment covers + this provider id or the user config includes them. + +Falsification: + +- Inspect `enrichProviderFromCatalog("umans", prov)` behavior for this provider name. +- Add/verify a first-class `umans` registry entry with model constraints and compare request bodies. + +## Patch options + +### Option A: first-class Umans provider presets + +Add registry entries: + +- `umans` or `umans-ai` + - label: `Umans AI` + - adapter: `anthropic` + - baseUrl: `https://api.code.umans.ai` + - authKind: `key` + - dashboardUrl: `https://app.umans.ai/offers/code/docs` + - defaultModel: `umans-coder` + - models: `umans-coder`, `umans-kimi-k2.7`, `umans-glm-5.2`, `umans-flash`, possibly + `umans-glm-5.1` +- `umans-openai` optional advanced preset + - adapter: `openai-chat` + - baseUrl: `https://api.code.umans.ai/v1` + - note: OpenAI-compatible route; use only if a tool specifically requires Chat Completions. + +Reasoning: + +- Make the safer coding-agent path the obvious default. +- Keep Chat Completions available for users who need it. + +### Option B: custom provider protocol helper + +Improve the custom provider modal: + +- Add an adapter explainer: + - `openai-chat`: base URL should end in `/v1`; opencodex calls `/chat/completions`. + - `anthropic`: base URL should be provider root or `/v1`; opencodex calls `/v1/messages`. +- Add a preset-like "Umans Messages" row so users do not accidentally keep `openai-chat`. +- Optionally warn when a base URL contains `api.code.umans.ai` and adapter is still `openai-chat`: + "Umans also supports Messages; use Anthropic adapter for Codex-style tool stability." + +### Option C: harden OpenAI Chat streaming parser + +Add tests and parser support for: + +- id-less tool-call indexed chunks. +- multiple tool-call indexes even when `parallel_tool_calls=false`. +- object-shaped or cumulative tool arguments if observed in raw stream. +- optional duplicate/cumulative text detection only if raw Umans evidence proves it; do not guess. + +### Option D: raw stream capture debug mode + +Add a temporary or gated debug facility: + +- Log upstream SSE frames for a selected provider/model with secrets stripped. +- Include request adapter, URL path, and model params. +- Use it to prove whether duplication comes from upstream frames, opencodex bridge, or Codex + mobile renderer. + +## Recommendation + +Do not treat #18 as a generic stall timeout issue. It is likely a wire-protocol / event-contract +problem. + +Immediate next implementation cycle should: + +1. Add first-class Umans presets, defaulting to `adapter: "anthropic"` / `/v1/messages`. +2. Add documentation for when custom providers should use `anthropic` instead of `openai-chat`. +3. Add an A/B repro plan for Umans `openai-chat` vs `anthropic`. +4. Add raw-stream capture tests before changing duplicate suppression logic. +5. Harden `openai-chat` tool-call parsing only from observed raw stream evidence. + +## Evidence status + +- Umans protocol support: sufficient. Confirmed from official Umans docs and models.dev metadata. +- Reporter config: insufficient. Local machine does not have `providers.umans`. +- Root cause of #18: partial. Strong hypotheses exist, but raw upstream SSE capture is required before + changing streaming parser behavior. diff --git a/devlog/230_umans-wire-protocol/01_oh_my_pi_findings.md b/devlog/230_umans-wire-protocol/01_oh_my_pi_findings.md new file mode 100644 index 00000000000..dfef0290fbb --- /dev/null +++ b/devlog/230_umans-wire-protocol/01_oh_my_pi_findings.md @@ -0,0 +1,315 @@ +# 230.1 Oh My Pi Umans implementation findings + +## Goal + +Inspect how `can1357/oh-my-pi` implements Umans, specifically whether it treats Umans as an +OpenAI-compatible Chat Completions provider or as an Anthropic Messages provider. + +Repository: + +- https://github.com/can1357/oh-my-pi + +Local reference clone: + +- `/Users/jun/Developer/new/700_projects/jawcode/devlog/_upstream_omp` +- Checked at `cc0c67beb chore: bump version to 16.1.13` +- `git status --short --branch`: `## main...origin/main` + +## Summary + +Oh My Pi treats Umans as an **Anthropic Messages** provider. + +It does not make Umans' coding-plan login validate against `/v1/chat/completions`. It validates +the pasted key against: + +- `POST https://api.code.umans.ai/v1/messages` +- `anthropic-version: 2023-06-01` +- `x-api-key: ` +- model: `umans-coder` + +Its catalog also maps Umans models to: + +- `api: "anthropic-messages"` +- `provider: "umans"` +- `baseUrl: "https://api.code.umans.ai"` + +This strongly supports making opencodex's first-class Umans preset use the `anthropic` adapter, +not the `openai-chat` adapter. + +## Evidence + +### Provider registry + +Source: + +- https://github.com/can1357/oh-my-pi/blob/main/packages/ai/src/registry/umans.ts +- `/Users/jun/Developer/new/700_projects/jawcode/devlog/_upstream_omp/packages/ai/src/registry/umans.ts` + +Observed implementation: + +```ts +export const loginUmans = createApiKeyLogin({ + providerLabel: "Umans AI Coding Plan", + authUrl: "https://app.umans.ai/billing", + instructions: "Create or copy your Umans API key from Dashboard -> API Keys.", + promptMessage: "Paste your Umans API key", + placeholder: "sk-...", + validation: { + kind: "anthropic-messages", + provider: "Umans AI Coding Plan", + baseUrl: "https://api.code.umans.ai", + model: "umans-coder", + }, +}); + +export const umansProvider = { + id: "umans", + name: "Umans AI Coding Plan", + login: (cb: OAuthLoginCallbacks) => loginUmans(cb), +}; +``` + +Interpretation: + +- The provider id is `umans`. +- The user-facing login is API-key paste, not OAuth. +- Validation kind is `anthropic-messages`. +- Base URL intentionally omits `/v1`; the Anthropic client appends `/v1/messages`. + +### API-key validation test + +Source: + +- https://github.com/can1357/oh-my-pi/blob/main/packages/ai/test/umans-login.test.ts +- `/Users/jun/Developer/new/700_projects/jawcode/devlog/_upstream_omp/packages/ai/test/umans-login.test.ts` + +Observed assertions: + +```ts +expect(url).toBe("https://api.code.umans.ai/v1/messages"); +expect(init?.method).toBe("POST"); +expect(headers.get("content-type")).toBe("application/json"); +expect(headers.get("anthropic-version")).toBe("2023-06-01"); +expect(headers.get("x-api-key")).toBe("sk-umans-valid"); +expect(headers.get("authorization")).toBeNull(); +expect(body.model).toBe("umans-coder"); +expect(body.max_tokens).toBe(1); +``` + +Interpretation: + +- Oh My Pi explicitly forbids Bearer auth for this validation path. +- It validates using `x-api-key`. +- It validates with `umans-coder`, not `umans-kimi-k2.7`. + +### Catalog mapping test + +Source: + +- https://github.com/can1357/oh-my-pi/blob/main/packages/catalog/test/umans-provider.test.ts + +Observed assertions: + +```ts +expect(model).toMatchObject({ + id: "umans-coder", + api: "anthropic-messages", + provider: "umans", + baseUrl: "https://api.code.umans.ai", + reasoning: true, + input: ["text", "image"], + contextWindow: 262_144, + maxTokens: 32_768, + thinking: { defaultLevel: "medium" }, + compat: { escapeBuiltinToolNames: true }, +}); +``` + +For `umans-kimi-k2.7`: + +```ts +expect(mandatoryReasoningModel).toMatchObject({ + id: "umans-kimi-k2.7", + reasoning: true, + maxTokens: 32_768, + thinking: { defaultLevel: "medium", requiresEffort: true }, + compat: { escapeBuiltinToolNames: true }, +}); +``` + +Interpretation: + +- Oh My Pi models Umans Kimi as mandatory-reasoning Anthropic Messages. +- It adds `compat.escapeBuiltinToolNames: true` for Umans. +- It caps `umans-kimi-k2.7` max output at 32,768 despite some public metadata showing larger plan + ceilings. + +### Dynamic model discovery + +Source: + +- https://github.com/can1357/oh-my-pi/blob/main/packages/catalog/src/provider-models/openai-compat.ts + +Observed implementation: + +- `UMANS_BASE_URL = "https://api.code.umans.ai"` +- `UMANS_MODELS_INFO_PATH = "/models/info"` +- `umansModelManagerOptions()` returns `ModelManagerOptions<"anthropic-messages">` +- dynamic discovery fetches: + - `GET https://api.code.umans.ai/v1/models/info` + - optional `x-api-key` +- every mapped Umans model has: + - `api: "anthropic-messages"` + - `provider: "umans"` + - `baseUrl: "https://api.code.umans.ai"` + - `compat.escapeBuiltinToolNames: true` + +Important comment: + +```ts +// Umans `models/info` reports `supports_vision: true` for natively +// vision-capable models and a non-empty string sentinel (e.g. +// `"via-handoff"`) for models that route image inputs through a vision +// handoff pre-analysis step instead of accepting raw image blocks. Only +// `true` means the model accepts image content directly; sentinel values +// MUST map to text-only ... +``` + +Interpretation: + +- Umans has a custom `/v1/models/info` endpoint with richer capability metadata. +- `supports_vision: "via-handoff"` must not be treated as native image support. +- Oh My Pi drops cached/stale GLM rows if static metadata says those rows are text-only. + +### Host compatibility + +Source: + +- https://github.com/can1357/oh-my-pi/blob/main/packages/catalog/src/hosts.ts +- https://github.com/can1357/oh-my-pi/blob/main/packages/catalog/src/compat/anthropic.ts + +Observed implementation: + +```ts +umans: { providers: ["umans"], urlMarkers: ["api.code.umans.ai"] } +``` + +Anthropic compat sets: + +```ts +escapeBuiltinToolNames: modelMatchesHost(spec, "umans") +``` + +Interpretation: + +- Umans is a known host class. +- Built-in tool names are escaped for Umans on the Anthropic Messages path. +- This is likely a defensive fix for provider-side reserved/built-in Anthropic tool names. + +### Bundled model snapshot + +Source: + +- https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/catalog/src/models.json + +Observed Umans models: + +- `umans-coder` +- `umans-flash` +- `umans-glm-5.1` +- `umans-glm-5.2` +- `umans-kimi-k2.6` +- `umans-kimi-k2.7` +- `umans-qwen3.6-35b-a3b` + +All are bundled as: + +```json +{ + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "compat": { + "escapeBuiltinToolNames": true + } +} +``` + +Notable model metadata: + +- `umans-coder`: text+image, context 262,144, maxTokens 32,768, thinking requires effort. +- `umans-kimi-k2.7`: text+image, context 262,144, maxTokens 32,768, thinking requires effort. +- `umans-glm-5.1` and `umans-glm-5.2`: text-only, because image support is treated as + via-handoff, not native image blocks. +- `umans-glm-5.2`: context 405,504, maxTokens 131,071. + +## Implications for opencodex + +### 1. Default Umans preset should be `anthropic` + +Recommended opencodex provider seed: + +```json +{ + "adapter": "anthropic", + "baseUrl": "https://api.code.umans.ai", + "authMode": "key", + "defaultModel": "umans-coder", + "models": [ + "umans-coder", + "umans-kimi-k2.7", + "umans-kimi-k2.6", + "umans-flash", + "umans-glm-5.2", + "umans-glm-5.1", + "umans-qwen3.6-35b-a3b" + ] +} +``` + +### 2. opencodex may need an Umans-specific Anthropic compat flag + +Oh My Pi sets `escapeBuiltinToolNames: true` for Umans. opencodex currently only has generic +Anthropic tool mapping and does not expose an equivalent provider flag. If issue #18 includes +tool/file-change instability on Umans, this is a concrete compatibility gap to inspect. + +Patch direction: + +- Add an optional provider flag such as `escapeBuiltinToolNames?: boolean`, or hard-code for provider + id/base URL `umans` / `api.code.umans.ai`. +- Apply it only inside the Anthropic adapter's tool-name mapping path. + +### 3. Umans dynamic model metadata should prefer `/v1/models/info` + +Oh My Pi does not rely only on generic `/models`. It uses: + +- `GET https://api.code.umans.ai/v1/models/info` + +Patch direction: + +- For opencodex, a first pass can ship static model metadata in `PROVIDER_REGISTRY`. +- A second pass can add Umans-specific live metadata enrichment for context/output/vision/thinking + flags. + +### 4. `openai-chat` should remain an advanced fallback only + +Umans does expose Chat Completions, but Oh My Pi's coding-agent path deliberately chooses +Anthropic Messages. For opencodex/Codex, the safer UX is: + +- default: `umans` -> `anthropic` +- optional advanced: `umans-openai` -> `openai-chat` + +## Conclusion + +Oh My Pi strongly confirms the previous opencodex research: + +- Umans should be treated as an Anthropic Messages provider for coding-agent usage. +- API-key validation should call `/v1/messages` with `x-api-key`, not `/chat/completions` with + Bearer. +- Umans model metadata should include mandatory reasoning, text-only GLM via-handoff handling, and + built-in tool-name escaping. + +For opencodex, the next implementation should add a first-class Umans provider preset using the +`anthropic` adapter and then test issue #18 against that path before attempting duplicate-suppression +logic in the OpenAI Chat adapter. diff --git a/devlog/241_umans-provider-cleanup/00_note.md b/devlog/241_umans-provider-cleanup/00_note.md new file mode 100644 index 00000000000..f970f0fb109 --- /dev/null +++ b/devlog/241_umans-provider-cleanup/00_note.md @@ -0,0 +1,21 @@ +# Umans provider cleanup note + +## Context + +During the PR #14/#19 local dev merge stop audit, an unrelated Umans provider WIP appeared in the working tree. The PR #14/#19 commits themselves were already verified, but the dirty tree meant dev was not ready for a later main merge decision. + +## Scope + +- Add Umans AI Coding Plan as a key-login Anthropic-compatible provider. +- Preserve provider runtime metadata when saving API-key logins. +- Validate Anthropic-compatible API keys with the Messages API shape instead of `/models`. +- Escape builtin tool names on the wire for gateways that require it and strip the prefix before returning tool calls to Codex. +- Document the new provider/config flag in README and docs-site locale pages. + +## Verification + +- `bun run typecheck` +- `bun test tests/umans-provider.test.ts tests/provider-registry-parity.test.ts` +- `bun test tests` +- `bun run build:gui` + diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index 89b10c66485..5d647d3b8e4 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -17,10 +17,14 @@ opencodex는 `~/.opencodex/config.json`으로 설정됩니다. 이 파일은 `oc | `subagentModels?` | `string[]` | — | Codex의 서브에이전트 선택기에서 가장 먼저 노출되는 최대 5개의 `provider/model` id. | | `disabledModels?` | `string[]` | — | Codex에서 숨겨지는 라우팅된 `provider/model` id (카탈로그와 `/v1/models`에서 제외됨). | | `websockets?` | `boolean` | `false` | Codex가 Responses WebSocket 경로를 사용하도록 `supports_websockets`를 광고합니다. 생략하거나 `false`로 두면 HTTP/SSE를 유지합니다. | +| `syncResumeHistory?` | `boolean` | `false` | Codex App 히스토리 호환 모드. 켜면 opencodex가 원래 Codex thread metadata를 백업하고, 기존 OpenAI interactive row를 `opencodex`로 remap하며, opencodex가 만든 `exec` row를 App에 보이는 source로 임시 승격합니다. `ocx stop` / `ocx restore`는 백업된 OpenAI row를 복원하고, 남은 opencodex user thread는 OpenAI로 eject해서 `config.toml`에서 프록시 provider가 제거된 뒤에도 native Codex가 resume할 수 있게 합니다. | | `modelCacheTtlMs?` | `number` | `300000` | 프로바이더별 `/models` 캐시의 유효 기간 (5분). | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | 웹 검색 사이드카 옵션 (아래 참조). | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | 비전 사이드카 옵션 (아래 참조). | +백업 지원 이전의 개발 빌드에서 이미 `syncResumeHistory`를 실행했다면, +`ocx recover-history --legacy-openai`로 같은 native-provider 복구를 명시 실행할 수도 있습니다. + ## 프로바이더 (`OcxProviderConfig`) | Field | Type | Meaning | @@ -34,6 +38,7 @@ opencodex는 `~/.opencodex/config.json`으로 설정됩니다. 이 파일은 `oc | `authMode?` | `"key" \| "forward" \| "oauth"` | 인증 방식 (기본 `key`). [프로바이더](/opencodex/ko/guides/providers/#auth-modes) 참조. | | `noReasoningModels?` | `string[]` | reasoning/thinking 파라미터를 거부하는 모델 — 어댑터가 이들에 대해 `reasoning_effort`를 제거함. | | `noVisionModels?` | `string[]` | 텍스트 전용 모델 — [비전 사이드카](/opencodex/ko/guides/sidecars/)가 이들을 위해 이미지를 설명함. 매칭 시 Ollama의 `:size` 태그를 허용함. | +| `escapeBuiltinToolNames?` | `boolean` | Umans 같은 Anthropic 호환 게이트웨이가 wire에서 도구명 escape를 요구할 때 사용합니다. opencodex는 Codex에 tool call을 돌려주기 전에 prefix를 제거합니다. | ## 사이드카 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index c309819c528..0f49ab36ea5 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -17,10 +17,14 @@ default (a single `openai` forward provider). | `subagentModels?` | `string[]` | — | Up to 5 `provider/model` ids featured first in Codex's subagent picker. | | `disabledModels?` | `string[]` | — | Routed `provider/model` ids hidden from Codex (excluded from the catalog and `/v1/models`). | | `websockets?` | `boolean` | `false` | Advertise `supports_websockets` so Codex uses the Responses WebSocket path. Omit or set `false` to keep HTTP/SSE. | +| `syncResumeHistory?` | `boolean` | `false` | Reversible Codex App history compatibility mode. When enabled, opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | Web-search sidecar options (see below). | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | Vision sidecar options (see below). | +If an older development build already ran `syncResumeHistory` before backup support existed, you can +also force the same native-provider recovery with `ocx recover-history --legacy-openai`. + ## Providers (`OcxProviderConfig`) | Field | Type | Meaning | @@ -34,6 +38,7 @@ default (a single `openai` forward provider). | `authMode?` | `"key" \| "forward" \| "oauth"` | How to authenticate (default `key`). See [Providers](/opencodex/guides/providers/#auth-modes). | | `noReasoningModels?` | `string[]` | Models that reject a reasoning/thinking param — the adapter drops `reasoning_effort` for them. | | `noVisionModels?` | `string[]` | Text-only models — the [vision sidecar](/opencodex/guides/sidecars/) describes images for them. Matching tolerates an Ollama `:size` tag. | +| `escapeBuiltinToolNames?` | `boolean` | Anthropic-compatible gateways such as Umans can require tool-name escaping on the wire; opencodex strips the prefix before returning tool calls to Codex. | ## Sidecars diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index e8452981b8b..417326c74f8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -15,10 +15,14 @@ opencodex 通过 `~/.opencodex/config.json` 进行配置。它由 `ocx init` 和 | `subagentModels?` | `string[]` | — | 最多 5 个 `provider/model` id,会在 Codex 的 subagent 选择器中优先展示。 | | `disabledModels?` | `string[]` | — | 从 Codex 中隐藏的已路由 `provider/model` id(从目录和 `/v1/models` 中排除)。 | | `websockets?` | `boolean` | `false` | 广告 `supports_websockets`,让 Codex 使用 Responses WebSocket 路径。省略或设为 `false` 会保持 HTTP/SSE。 | +| `syncResumeHistory?` | `boolean` | `false` | Codex App 历史兼容模式。启用后,opencodex 会备份原始 Codex thread metadata,把旧的 OpenAI interactive row remap 到 `opencodex`,并临时把 opencodex 创建的 `exec` row 提升为 App 可见的 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余的 opencodex user thread eject 到 OpenAI,这样在 `config.toml` 中移除 proxy provider 后 native Codex 仍可 resume。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存的有效期(5 分钟)。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 开启 | 网络搜索 sidecar 选项(见下文)。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 开启 | 视觉 sidecar 选项(见下文)。 | +如果旧的开发构建在备份支持出现之前已经运行过 `syncResumeHistory`,也可以显式运行 +`ocx recover-history --legacy-openai` 执行同样的 native-provider 恢复。 + ## Providers(`OcxProviderConfig`) | Field | Type | 含义 | @@ -32,6 +36,7 @@ opencodex 通过 `~/.opencodex/config.json` 进行配置。它由 `ocx init` 和 | `authMode?` | `"key" \| "forward" \| "oauth"` | 认证方式(默认 `key`)。见 [Providers](/opencodex/zh-cn/guides/providers/#auth-modes)。 | | `noReasoningModels?` | `string[]` | 会拒绝 reasoning/thinking 参数的模型 —— adapter 会为它们丢弃 `reasoning_effort`。 | | `noVisionModels?` | `string[]` | 纯文本模型 —— [视觉 sidecar](/opencodex/zh-cn/guides/sidecars/) 会为它们描述图像。匹配时可容忍 Ollama 的 `:size` 标签。 | +| `escapeBuiltinToolNames?` | `boolean` | Umans 等 Anthropic 兼容网关可能要求在 wire 上转义工具名;opencodex 会在把 tool call 返回给 Codex 前移除前缀。 | ## Sidecars diff --git a/gui/src/App.tsx b/gui/src/App.tsx index cc236510d33..76271b391b8 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -24,6 +24,12 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ const THEME_ICON = { light: IconSun, dark: IconMoon, system: IconMonitor } as const; const THEME_TKEY: Record = { light: "theme.light", dark: "theme.dark", system: "theme.system" }; +function readRuntimeVersion(data: unknown): string | null { + if (!data || typeof data !== "object" || !("version" in data)) return null; + const version = (data as { version?: unknown }).version; + return typeof version === "string" && version.length > 0 ? version : null; +} + function readStoredTheme(): Theme { const t = localStorage.getItem(THEME_KEY); return t === "light" || t === "dark" ? t : "system"; @@ -32,6 +38,7 @@ function readStoredTheme(): Theme { export default function App() { const [page, setPage] = useState("dashboard"); const [theme, setTheme] = useState(readStoredTheme); + const [runtimeVersion, setRuntimeVersion] = useState(null); const { locale, setLocale } = useI18n(); const t = useT(); @@ -43,8 +50,26 @@ export default function App() { else { el.setAttribute("data-theme", theme); localStorage.setItem(THEME_KEY, theme); } }, [theme]); + useEffect(() => { + let cancelled = false; + const fetchRuntimeVersion = async () => { + try { + const res = await fetch(`${API_BASE}/healthz`); + if (!res.ok) return; + const version = readRuntimeVersion(await res.json()); + if (!cancelled && version) setRuntimeVersion(version); + } catch { + // Keep the build-time fallback when the proxy is unavailable. + } + }; + fetchRuntimeVersion(); + const interval = setInterval(fetchRuntimeVersion, 30000); + return () => { cancelled = true; clearInterval(interval); }; + }, []); + const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); const ThemeIcon = THEME_ICON[theme]; + const displayedVersion = runtimeVersion ?? __APP_VERSION__; const langName = LOCALES.find(l => l.code === locale)?.name ?? "English"; const cycleLang = () => { @@ -65,7 +90,7 @@ export default function App() {
opencodex - v{__APP_VERSION__} + v{displayedVersion}