Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c4725b2
fix(pricing): preserve concurrent rates when resetting the last live …
invalid-email-address Sep 7, 2026
0aef26c
fix(adapters): require host error context before recovery guidance
invalid-email-address Sep 7, 2026
e9d72e0
fix(oauth): accept documented Orca key grants and clarify private end…
invalid-email-address Sep 7, 2026
cb24485
fix(gui): serialize OAuth restarts behind pending cancellation
invalid-email-address Sep 7, 2026
7768800
fix(cursor): preserve tool results outside verified code-mode errors
invalid-email-address Sep 7, 2026
e7173ed
fix(gui): retain unapplied Desktop preference state across refreshes
invalid-email-address Sep 7, 2026
3baad73
fix(usage): reject custom reports with missing or mismatched window r…
invalid-email-address Sep 7, 2026
7d8082c
fix(gui): keep subagent roster usable when fallback settings fail
invalid-email-address Sep 7, 2026
ba5e4e8
docs: record release regression persistence and response contracts
invalid-email-address Sep 7, 2026
7df1b9e
Merge remote-tracking branch 'origin/dev' into codex/release-247-regr…
invalid-email-address Sep 7, 2026
18b8890
docs: explain custom report confirmation and price reset scope
invalid-email-address Sep 7, 2026
713be5c
refactor(gui): chain cancellation barriers without an await loop
invalid-email-address Sep 7, 2026
4437cd8
test(gui): observe dashboard hook state after React commits
invalid-email-address Sep 7, 2026
42f2afd
refactor(gui): give settings polling and mutation explicit reducer ow…
invalid-email-address Sep 7, 2026
48ab3e1
fix(gui): guard polling input and precompute model membership
invalid-email-address Sep 7, 2026
5725219
test(gui): follow the shared OAuth cancellation request owner
invalid-email-address Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,26 @@ For a split self-hosted deployment, set `ORCAROUTER_API_BASE_URL` and
`ORCAROUTER_AUTH_BASE_URL` separately.

The value must be an HTTPS origin (or HTTP loopback for local development) with no credentials,
query, or fragment. Re-run the login after a relay `401`; OrcaRouter keys are durable and do not
have a refresh-token grant.
query, or fragment. Before the first login to a loopback/private self-hosted endpoint, explicitly
allow that destination in your `~/.opencodex/config.json` provider row. For example, merge this
entry into the existing `providers` object for a local development server:

```json
{
"orcarouter-oauth": {
"adapter": "openai-chat",
"baseUrl": "http://127.0.0.1:9999/v1",
"authMode": "oauth",
"allowPrivateNetwork": true
}
}
```

Then run `ORCAROUTER_BASE_URL=http://127.0.0.1:9999 ocx login orcarouter-oauth`.
Login preserves this explicit consent; setting the URL alone never enables private-network access.
Without the opt-in, destination validation rejects inference and model discovery for that endpoint.
This requirement concerns the provider endpoint; the browser callback listener needs no such opt-in.
Re-run the login after a relay `401`; OrcaRouter keys are durable and do not have a refresh-token grant.

**Meta Model API (`meta-model`).** Muse Spark on Meta's own OpenAI-compatible endpoint,
served over `/v1/responses`. Create a key in
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ on. **Logs** works the same way with `#logs` and `#logs/debug`. An older `#provi
bookmark now lands on `#providers`.

Cost values in **Logs** and **Usage** are API list-price equivalents calculated from reported tokens.
For a custom usage interval, the server must confirm the exact requested start and end times.
If an older running proxy does not support those bounds, the dashboard and CLI reject its report;
upgrade and restart that proxy before retrying. Resetting a manual model price affects only that
model, preserving other rates saved independently.
They are not billing receipts or evidence of an actual charge; subscription usage or provider credits
may apply instead.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,12 @@ an account credential. The public defaults intentionally split authentication
(`https://www.orcarouter.ai`) from inference (`https://api.orcarouter.ai/v1`). Set
`ORCAROUTER_BASE_URL` before the first account login for a one-origin self-hosted deployment, or use
`ORCAROUTER_AUTH_BASE_URL` and `ORCAROUTER_API_BASE_URL` for separate origins.
For a loopback/private self-hosted endpoint, **before the first login**, create or update
`providers["orcarouter-oauth"]` with `adapter: "openai-chat"`, the intended `baseUrl`,
`authMode: "oauth"`, and an explicit `allowPrivateNetwork: true`. Login preserves that operator
setting and never grants it from a URL override. Without it, destination validation rejects the
local endpoint for inference and model discovery. The OAuth browser callback listener itself
does not require this provider opt-in. See the [OrcaRouter setup example](/guides/providers/).

## Provider diagnostic outbound safety

Expand Down
18 changes: 18 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,24 @@ ORCAROUTER_BASE_URL=https://router.example ocx login orcarouter-oauth
`ORCAROUTER_API_BASE_URL`。

该值必须是 HTTPS origin(本地开发可使用 HTTP loopback),且不能包含用户名密码、query 或 fragment。
首次登录回环或私有网络中的自托管服务前,必须在 `~/.opencodex/config.json` 中明确允许访问该地址。
例如,将以下条目合并到现有的 `providers` 对象中,用于本地开发服务:

```json
{
"orcarouter-oauth": {
"adapter": "openai-chat",
"baseUrl": "http://127.0.0.1:9999/v1",
"authMode": "oauth",
"allowPrivateNetwork": true
}
}
```

然后运行 `ORCAROUTER_BASE_URL=http://127.0.0.1:9999 ocx login orcarouter-oauth`。
登录会保留这项明确授权;仅设置 URL 不会自动启用私有网络访问。
未设置此选项时,目标地址校验会拒绝该服务的推理和模型发现请求。
此要求针对 provider 的服务地址,浏览器回调监听器不需要此选项。
若 relay 返回 `401`,重新运行登录即可;OrcaRouter 签发的是长期 API key,不存在 refresh-token grant。

**Command Code 配额:**仪表盘和 `ocx account refresh` 会在规范主机 `https://api.commandcode.ai` 上探测 `/alpha/billing/credits` 窗口(5 小时和每周)。OAuth 预设 (`command-code`) 使用已保存的账户 bearer;Provider-API 密钥预设 (`commandcode`) 使用当前配置的有效密钥。用户改写后的仿冒 base URL 不会被探测。当 Command Code 同时返回周期消耗时,剩余的 monthly / purchased / free credits 会显示为 USD 窗口。
Expand Down
23 changes: 13 additions & 10 deletions gui/src/components/ModelPickerOrderEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type DragEvent } from "react";
import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch";
import { readJsonOrThrow } from "../fetch-json";
import { IconArrowDown, IconArrowUp, IconGrip } from "../icons";
Expand Down Expand Up @@ -133,21 +133,24 @@ export default function ModelPickerOrderEditor({ apiBase, active, identities, on
setAnnouncement(t("models.pickerOrder.position", { model: id, position: next.indexOf(id) + 1, total: next.length }));
clearDrag();
};
const movable = (id: string) => !disabled && draft.includes(id) && !snapshot?.fixed.includes(id);
const draftSet = new Set(draft);
const fixedSet = new Set(snapshot?.fixed);
const movable = (id: string) => !disabled && draftSet.has(id) && !fixedSet.has(id);
const dragOver = (event: DragEvent<HTMLLIElement>, id: string) => {
if (!lifetime.current.drag || lifetime.current.drag.id === id || !movable(lifetime.current.drag.id) || !movable(id)
|| !event.dataTransfer.types.includes(DRAG_TYPE)) return;
event.preventDefault(); event.dataTransfer.dropEffect = "move"; setOver(id);
};
return <section className="picker-order-editor" aria-label={t("models.pickerOrder.custom")} aria-busy={busy}>
<p className="muted text-label">{t("models.pickerOrder.editorHint")}</p>
{(blocked || identityChanged) && <p role="alert">{t(blocked ?? "models.pickerOrder.changed")}</p>}
{error && <p role="alert">{t("models.pickerOrder.requestFailed")}</p>}
{snapshot && draft.length === 0 && <p>{t("models.pickerOrder.empty")}</p>}
<ol className="picker-order-list">
{draft.map((id, index) => {
const fixed = snapshot?.fixed.includes(id) === true;
const fixed = fixedSet.has(id);
return <li key={id} className={`picker-order-row${dragging === id ? " cwi-target-row--dragging" : ""}${over === id ? " cwi-target-row--drop" : ""}`}
onDragOver={event => {
if (!lifetime.current.drag || lifetime.current.drag.id === id || !movable(lifetime.current.drag.id) || !movable(id)
|| !event.dataTransfer.types.includes(DRAG_TYPE)) return;
event.preventDefault(); event.dataTransfer.dropEffect = "move"; setOver(id);
}}
onDragOver={event => dragOver(event, id)}
onDragLeave={() => setOver(null)}
onDrop={event => {
const source = lifetime.current.drag;
Expand All @@ -167,12 +170,12 @@ export default function ModelPickerOrderEditor({ apiBase, active, identities, on
{fixed && <span className="muted text-caption">{t("models.pickerOrder.featured")}</span>}
<span className="picker-order-actions">
<button type="button" className="btn btn-ghost btn-sm"
disabled={disabled || fixed || index === 0 || snapshot?.fixed.includes(draft[index - 1]!)}
disabled={disabled || fixed || index === 0 || fixedSet.has(draft[index - 1]!)}
aria-label={t("models.pickerOrder.upModel", { model: id })}
onClick={() => move(id, stepPickerOrder(draft, id, -1, snapshot?.fixed ?? []))}>
<IconArrowUp width={14} height={14} aria-hidden="true" /></button>
<button type="button" className="btn btn-ghost btn-sm"
disabled={disabled || fixed || index === draft.length - 1 || snapshot?.fixed.includes(draft[index + 1]!)}
disabled={disabled || fixed || index === draft.length - 1 || fixedSet.has(draft[index + 1]!)}
aria-label={t("models.pickerOrder.downModel", { model: id })}
onClick={() => move(id, stepPickerOrder(draft, id, 1, snapshot?.fixed ?? []))}>
<IconArrowDown width={14} height={14} aria-hidden="true" /></button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export default function SubagentDelegationSection({
const nativeMayUseV2 = ultraMode.enabled || (ultraMode.multiAgentMode !== "v1"
&& !(ultraMode.multiAgentMode === "v2" && ultraMode.keepNativeChatGptOnV1));
const showV2Compatibility = !ultraLoadFailed && ultraMode.loaded === true && routedPreferred && nativeMayUseV2;
const availableModelSet = new Set(availableModels);
const fallbackSet = new Set(fallback);
const [pollDraft, setPollDraft] = useState(() => ({ pollMs: fallbackPollMs, text: String(fallbackPollMs) }));
// Keep blank/invalid input text while reconciling accepted settings from a load or save.
if (!Object.is(pollDraft.pollMs, fallbackPollMs)) {
setPollDraft({ pollMs: fallbackPollMs, text: Number.isFinite(fallbackPollMs) ? String(fallbackPollMs) : "" });
}
const fallbackControlsRef = useRef<HTMLDivElement>(null);
const [identity, setIdentity] = useState(() => ({
models: fallback,
Expand Down Expand Up @@ -172,7 +179,7 @@ export default function SubagentDelegationSection({
{fallback.map((modelName, index) => (
<div key={rows[index].id} className="swi-fallback-row">
<span className="swi-fallback-model">{index + 1}. {modelName}
{!availableModels.includes(modelName) && <span className="muted setting-hint">{t("sub.fallbackUnavailable")}</span>}
{!availableModelSet.has(modelName) && <span className="muted setting-hint">{t("sub.fallbackUnavailable")}</span>}
</span>
<span className="swi-fallback-actions">
<button type="button" className="btn btn-ghost btn-icon btn-sm" data-action="up" onClick={() => moveFallback(index, -1)} disabled={fallbackBusy || index === 0} aria-label={t("sub.moveUp", { m: modelName })}><IconArrowUp /></button>
Expand All @@ -188,10 +195,16 @@ export default function SubagentDelegationSection({
))}
<Select value="" label={t("sub.fallbackAdd")} options={[
{ value: "", label: t("sub.fallbackAdd") },
...availableModels.filter(modelName => !fallback.includes(modelName)).map(modelName => ({ value: modelName, label: modelName })),
]} onChange={value => { if (value && !fallback.includes(value)) onFallbackChange([...fallback, value]); }} disabled={fallbackBusy} />
...availableModels.filter(modelName => !fallbackSet.has(modelName)).map(modelName => ({ value: modelName, label: modelName })),
]} onChange={value => { if (value && !fallbackSet.has(value)) onFallbackChange([...fallback, value]); }} disabled={fallbackBusy} />
<label className="setting-hint">{t("sub.fallbackPoll")}
<input className="input" type="number" min={5000} max={600000} step={1000} value={fallbackPollMs} onChange={e => onFallbackPollMsChange(Number(e.target.value))} disabled={fallbackBusy} aria-invalid={!validPollMs} /> ms
<input className="input" type="number" min={5000} max={600000} step={1000} value={pollDraft.text} onChange={e => {
const text = e.currentTarget.value;
const parsed = Number(text);
const pollMs = text.trim() !== "" && Number.isFinite(parsed) ? parsed : Number.NaN;
setPollDraft({ pollMs, text });
onFallbackPollMsChange(pollMs);
}} disabled={fallbackBusy} aria-invalid={!validPollMs} /> ms
</label>
{!validPollMs && <div className="setting-hint" role="alert">{t("sub.fallbackPollInvalid")}</div>}
<button type="button" className="btn btn-primary btn-sm" onClick={onFallbackSave} disabled={fallbackBusy || !validPollMs}>{t("common.save")}</button>
Expand Down
28 changes: 14 additions & 14 deletions gui/src/components/use-add-provider-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import type { TFn } from "../i18n/shared";
import { readJsonIfOk } from "../fetch-json";
import { openBrowserRequestField } from "../oauth-open-browser-pref";
import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier";

export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000;

Expand Down Expand Up @@ -35,14 +36,8 @@ export function useAddProviderOAuth({
return generation;
}, []);

const cancelServerLogin = useCallback(async (providerId: string) => {
await fetch(`${apiBase}/api/oauth/login/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId }),
keepalive: true,
}).catch(() => undefined);
}, [apiBase]);
const cancelServerLogin = useCallback((providerId: string) =>
cancelOAuthLogin(apiBase, providerId), [apiBase]);

useEffect(() => {
const cancelActiveLogins = (clearUi: boolean) => {
Expand Down Expand Up @@ -97,15 +92,19 @@ export function useAddProviderOAuth({
setManualCodeMsg("");
setManualCodeOk(true);
try {
const res = await fetch(`${apiBase}/api/oauth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...openBrowserRequestField() }),
const res = await afterOAuthCancellation(apiBase, providerId, () => {
if (!aliveRef.current || !isCurrent()) return;
return fetch(`${apiBase}/api/oauth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...openBrowserRequestField() }),
});
});
if (!aliveRef.current || !isCurrent()) return;
if (!res || !aliveRef.current || !isCurrent()) return;
if (!res.ok) {
activeProvidersRef.current.delete(providerId);
const data = await res.json().catch(() => ({})) as { error?: string };
if (!aliveRef.current || !isCurrent()) return;
setOauthMsgTone("warn");
setOauthMsg(data.error === "unknown oauth provider"
? t("modal.oauthComingSoonShort")
Expand All @@ -116,6 +115,7 @@ export function useAddProviderOAuth({
// carry the only human-readable step. Keep all three: the hint renderer
// decides what to show, rather than this hook deciding what to discard.
const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string; error?: string };
if (!aliveRef.current || !isCurrent()) return;
setOauthUrl(data.url ?? "", providerId, data.deviceCode, data.instructions);
if (data.url || data.deviceCode) setOauthMsg(t("modal.waitingLogin"));
else setOauthMsg(data.instructions || t("modal.loggingIn"));
Expand Down Expand Up @@ -144,7 +144,7 @@ export function useAddProviderOAuth({
setOauthMsg(t("modal.loginTimeout"));
} catch {
if (isCurrent()) await cancelServerLogin(providerId);
activeProvidersRef.current.delete(providerId);
if (isCurrent()) activeProvidersRef.current.delete(providerId);
if (aliveRef.current && isCurrent()) {
setOauthMsgTone("warn");
setOauthMsg(t("modal.networkError"));
Expand Down
41 changes: 41 additions & 0 deletions gui/src/oauth-cancellation-barrier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Cancellation is provider-scoped on the server. Keep outstanding deliveries
// outside React instances so reopening either login surface cannot overtake one.
const cancellations = new Map<string, Promise<void>>();

export function cancelOAuthLogin(apiBase: string, provider: string): Promise<void> {
const key = JSON.stringify([apiBase, provider]);
const pending = cancellations.get(key);
if (pending) return pending;

const delivery = (async () => {
await fetch(`${apiBase}/api/oauth/login/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
keepalive: true,
});
})().catch(() => {
// Preserve best-effort cleanup: a transport failure must not wedge retries.
// Settlement is an ordering barrier, not proof of server cancellation.
}).finally(() => {
if (cancellations.get(key) === delivery) cancellations.delete(key);
});
cancellations.set(key, delivery);
return delivery;
}

export async function afterOAuthCancellation<T>(
apiBase: string,
provider: string,
start: () => T | Promise<T>,
): Promise<T> {
const key = JSON.stringify([apiBase, provider]);
const pending = cancellations.get(key);
if (pending) {
await pending;
return afterOAuthCancellation(apiBase, provider, start);
}
// Check the hook's generation and dispatch in the same turn as the barrier
// check, so another cancellation cannot slip into an extra await boundary.
return start();
}
Loading
Loading