@bitkyc08/opencodex 2.46.0 → 2.47.0-preview.20260908
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/SPONSORS.md +105 -0
- package/gui/dist/assets/index-B8ZZ0ivI.css +1 -0
- package/gui/dist/assets/index-CNTWhC5F.js +115 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/adapters/cursor/protobuf-request.ts +34 -21
- package/src/adapters/cursor/tool-guidance.ts +2 -2
- package/src/adapters/cursor/tool-result-normalize.ts +22 -2
- package/src/adapters/exec-tool-result-normalize.ts +87 -0
- package/src/adapters/kiro.ts +29 -18
- package/src/adapters/responses-code-mode.ts +14 -4
- package/src/adapters/tool-catalog-nudge.ts +2 -2
- package/src/bridge.ts +40 -24
- package/src/claude/inbound.ts +21 -6
- package/src/claude/outbound.ts +43 -26
- package/src/cli/capabilities.ts +27 -0
- package/src/cli/integrations.ts +1 -1
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +108 -1
- package/src/cli/observe.ts +18 -3
- package/src/cli/usage-report.ts +6 -1
- package/src/clients/config-export.ts +5 -3
- package/src/codex/auth-api.ts +36 -0
- package/src/codex/catalog/provider-fetch.ts +2 -1
- package/src/codex/quota-auto-refresh-state.ts +8 -0
- package/src/codex/quota-auto-refresh.ts +157 -27
- package/src/codex/warmup.ts +5 -0
- package/src/config/provider-validation.ts +70 -1
- package/src/config.ts +73 -1
- package/src/generated/compatibility-version.json +67 -55
- package/src/lib/json-byte-size.ts +61 -0
- package/src/lib/provider-outbound.ts +11 -10
- package/src/oauth/index.ts +42 -8
- package/src/oauth/orcarouter.ts +200 -0
- package/src/providers/key-store.ts +22 -0
- package/src/providers/registry.ts +78 -29
- package/src/responses/citation-markers.ts +56 -24
- package/src/responses/reasoning-envelope.ts +53 -19
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +7 -0
- package/src/server/chat-native.ts +65 -3
- package/src/server/claude-messages.ts +30 -10
- package/src/server/effort-policy.ts +183 -1
- package/src/server/management/agent-settings-routes.ts +50 -9
- package/src/server/management/config-routes.ts +2 -0
- package/src/server/management/logs-usage-routes.ts +11 -3
- package/src/server/management/model-routes.ts +65 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/provider-routes.ts +99 -6
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -7
- package/src/server/responses/compact.ts +3 -2
- package/src/server/responses/core.ts +16 -2
- package/src/server/startup-health-cache.ts +31 -9
- package/src/types/config.ts +5 -0
- package/src/types/provider.ts +4 -0
- package/src/usage/cost.ts +27 -31
- package/src/usage/summary.ts +52 -9
- package/src/usage/time-range.ts +48 -0
- package/src/usage/user-cost-overlays.ts +35 -3
- package/src/vision/anthropic-describe.ts +46 -2
- package/src/web-search/anthropic-executor.ts +49 -3
- package/src/web-search/parse.ts +1 -1
- package/gui/dist/assets/index-B72RPblC.js +0 -115
- package/gui/dist/assets/index-BFgUC17B.css +0 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError } from "./translator-budget";
|
|
2
|
+
|
|
3
|
+
/** Measure plain JSON data without allocating its serialized string or UTF-8 copy. */
|
|
4
|
+
export function jsonUtf8Bytes(value: unknown, limit = TRANSLATOR_MAX_TURN_BYTES): number {
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
const add = (count: number) => {
|
|
7
|
+
if (count > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit);
|
|
8
|
+
bytes += count;
|
|
9
|
+
};
|
|
10
|
+
const string = (text: string) => {
|
|
11
|
+
// Every UTF-16 code unit needs at least one JSON UTF-8 byte; reject large inputs
|
|
12
|
+
// before walking them. Escapes and unpaired surrogates are counted below.
|
|
13
|
+
if (text.length + 2 > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit);
|
|
14
|
+
add(2);
|
|
15
|
+
for (let i = 0; i < text.length; i++) {
|
|
16
|
+
const code = text.charCodeAt(i);
|
|
17
|
+
if (code === 0x22 || code === 0x5c || code === 8 || code === 9 || code === 10 || code === 12 || code === 13) add(2);
|
|
18
|
+
else if (code < 0x20) add(6);
|
|
19
|
+
else if (code < 0x80) add(1);
|
|
20
|
+
else if (code < 0x800) add(2);
|
|
21
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
22
|
+
const next = text.charCodeAt(i + 1);
|
|
23
|
+
if (next >= 0xdc00 && next <= 0xdfff) { add(4); i++; }
|
|
24
|
+
else add(6);
|
|
25
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) add(6);
|
|
26
|
+
else add(3);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const visit = (item: unknown): void => {
|
|
30
|
+
if (item === null) { add(4); return; }
|
|
31
|
+
if (typeof item === "string") { string(item); return; }
|
|
32
|
+
if (typeof item === "boolean") { add(item ? 4 : 5); return; }
|
|
33
|
+
if (typeof item === "number") { add(Number.isFinite(item) ? String(item).length : 4); return; }
|
|
34
|
+
if (Array.isArray(item)) {
|
|
35
|
+
add(2);
|
|
36
|
+
for (let i = 0; i < item.length; i++) {
|
|
37
|
+
if (i > 0) add(1);
|
|
38
|
+
if (item[i] === undefined) add(4);
|
|
39
|
+
else visit(item[i]);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (typeof item === "object" && item !== null) {
|
|
44
|
+
add(2);
|
|
45
|
+
let first = true;
|
|
46
|
+
for (const key of Object.keys(item)) {
|
|
47
|
+
const field = (item as Record<string, unknown>)[key];
|
|
48
|
+
if (field === undefined) continue;
|
|
49
|
+
if (!first) add(1);
|
|
50
|
+
first = false;
|
|
51
|
+
string(key);
|
|
52
|
+
add(1);
|
|
53
|
+
visit(field);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw new TypeError("Expected plain JSON data for translation sizing");
|
|
58
|
+
};
|
|
59
|
+
visit(value);
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
@@ -42,7 +42,7 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }>
|
|
|
42
42
|
*
|
|
43
43
|
* Under TUN mode the packet path intercepts the fake-IP destination itself, so a
|
|
44
44
|
* canonical registry destination whose local DNS answers include Clash fake-IP
|
|
45
|
-
* space (198.18.0.0/15) is reachable by pin-connecting through the TUN — no
|
|
45
|
+
* space (198.18.0.0/15 or fdfe:dcba:9876::/48) is reachable by pin-connecting through the TUN — no
|
|
46
46
|
* outbound HTTP(S) proxy env is required. The exception is deliberately narrow:
|
|
47
47
|
*
|
|
48
48
|
* - hostname-only: a literal 198.18.x.x URL never reaches it (the literal gate
|
|
@@ -143,11 +143,12 @@ async function providerOutboundRequest(
|
|
|
143
143
|
// below reason about the same value. `null` here means "no proxy fetch would actually use",
|
|
144
144
|
// even if some other proxy variable is set.
|
|
145
145
|
const effectiveProxy = effectiveProxyFor(parsed);
|
|
146
|
-
const
|
|
146
|
+
const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
|
|
147
|
+
const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed))
|
|
148
|
+
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name);
|
|
147
149
|
const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
|
|
148
150
|
const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
|
|
149
151
|
const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost;
|
|
150
|
-
const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
|
|
151
152
|
const allowPrivate = providerAllowsPrivateNetwork(name, provider);
|
|
152
153
|
let resolved: Awaited<ReturnType<typeof resolvePublicAddresses>>;
|
|
153
154
|
try {
|
|
@@ -169,11 +170,9 @@ async function providerOutboundRequest(
|
|
|
169
170
|
// pinned to the registry destination independently.
|
|
170
171
|
allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed))
|
|
171
172
|
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name),
|
|
172
|
-
// Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// is otherwise indistinguishable from a real private host, so proxy presence alone
|
|
176
|
-
// is not enough.
|
|
173
|
+
// Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound
|
|
174
|
+
// to a scheme-matched proxy (#3462) or under the TUN transparency exception for a
|
|
175
|
+
// canonical registry/accounting destination.
|
|
177
176
|
allowMihomoIpv6FakeIp,
|
|
178
177
|
});
|
|
179
178
|
} catch (error) {
|
|
@@ -187,11 +186,13 @@ async function providerOutboundRequest(
|
|
|
187
186
|
warnProxyDnsDegradationOnce();
|
|
188
187
|
return globalThis.fetch(url, { ...init, method, redirect: "manual" });
|
|
189
188
|
}
|
|
190
|
-
|
|
189
|
+
// A canonical TUN exception with no scheme-matched proxy must retain the
|
|
190
|
+
// validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
|
|
191
|
+
if (proxyConfigured && !resolved.privateNetwork && (effectiveProxy !== null || !allowMihomoIpv6FakeIp)) {
|
|
191
192
|
warnProxyBoundaryOnce();
|
|
192
193
|
// When the Mihomo exception could have admitted an answer, pin the transport to the
|
|
193
194
|
// proxy the admission assumed instead of letting fetch re-infer it from the environment.
|
|
194
|
-
const proxy = allowMihomoIpv6FakeIp ? effectiveProxy : undefined;
|
|
195
|
+
const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined;
|
|
195
196
|
return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
|
|
196
197
|
}
|
|
197
198
|
if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
|
package/src/oauth/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { loginCursor, refreshCursorToken } from "./cursor";
|
|
|
41
41
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
42
42
|
import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
|
|
43
43
|
import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
|
|
44
|
+
import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } from "./orcarouter";
|
|
44
45
|
import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
|
|
45
46
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
46
47
|
import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
|
|
@@ -180,7 +181,7 @@ export interface LoginFlowLifecycle {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
interface OAuthProviderDef {
|
|
183
|
-
login(ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials>;
|
|
184
|
+
login(ctrl: OAuthController, opts?: LoginOpts, providerConfig?: OcxProviderConfig): Promise<OAuthCredentials>;
|
|
184
185
|
refresh(
|
|
185
186
|
refreshToken: string,
|
|
186
187
|
signal?: AbortSignal,
|
|
@@ -188,6 +189,8 @@ interface OAuthProviderDef {
|
|
|
188
189
|
): Promise<OAuthCredentials>;
|
|
189
190
|
/** provider entry written into config.json on first login. */
|
|
190
191
|
providerConfig: OcxProviderConfig;
|
|
192
|
+
/** Resolve login-owned config from the latest disk state (for configurable OAuth origins). */
|
|
193
|
+
resolveProviderConfig?: (config: OcxConfig) => OcxProviderConfig;
|
|
191
194
|
defaultModel: string;
|
|
192
195
|
/**
|
|
193
196
|
* Built-in proactive-refresh policy, risk-tiered by the provider's ToS exposure (devlog
|
|
@@ -218,6 +221,27 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
218
221
|
defaultModel: oauthDefaultModel("command-code"),
|
|
219
222
|
defaultRefreshPolicy: "disabled",
|
|
220
223
|
},
|
|
224
|
+
"orcarouter-oauth": {
|
|
225
|
+
login: (ctrl, _opts, providerConfig) => loginOrcaRouter(ctrl, {
|
|
226
|
+
baseUrl: process.env.ORCAROUTER_API_BASE_URL
|
|
227
|
+
?? process.env.ORCAROUTER_BASE_URL
|
|
228
|
+
?? providerConfig?.baseUrl,
|
|
229
|
+
authBaseUrl: process.env.ORCAROUTER_AUTH_BASE_URL,
|
|
230
|
+
}),
|
|
231
|
+
refresh: refreshOrcaRouterKey,
|
|
232
|
+
providerConfig: oauthConfig("orcarouter-oauth"),
|
|
233
|
+
resolveProviderConfig: config => ({
|
|
234
|
+
...oauthConfig("orcarouter-oauth"),
|
|
235
|
+
baseUrl: orcaRouterInferenceBaseUrl(
|
|
236
|
+
process.env.ORCAROUTER_API_BASE_URL
|
|
237
|
+
?? process.env.ORCAROUTER_BASE_URL
|
|
238
|
+
?? config.providers["orcarouter-oauth"]?.baseUrl,
|
|
239
|
+
),
|
|
240
|
+
}),
|
|
241
|
+
defaultModel: oauthDefaultModel("orcarouter-oauth"),
|
|
242
|
+
// The credential is a durable API key. There is no refresh endpoint.
|
|
243
|
+
defaultRefreshPolicy: "disabled",
|
|
244
|
+
},
|
|
221
245
|
xai: {
|
|
222
246
|
// forceLogin skips the local grok-cli import so a SECOND account can be chosen in the browser.
|
|
223
247
|
login: (ctrl, opts) => loginXai(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
|
|
@@ -548,7 +572,13 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAu
|
|
|
548
572
|
}
|
|
549
573
|
|
|
550
574
|
/** Providers whose upstream-401 replay path may force a snapshot refresh. */
|
|
551
|
-
const FORCE_REFRESH_PROVIDERS = new Set([
|
|
575
|
+
const FORCE_REFRESH_PROVIDERS = new Set([
|
|
576
|
+
"xai",
|
|
577
|
+
"github-copilot",
|
|
578
|
+
"kiro",
|
|
579
|
+
"google-antigravity",
|
|
580
|
+
"orcarouter-oauth",
|
|
581
|
+
]);
|
|
552
582
|
|
|
553
583
|
export async function forceRefreshOAuthAccessSnapshot(
|
|
554
584
|
rejected: OAuthAccessSnapshot,
|
|
@@ -1442,10 +1472,11 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
1442
1472
|
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
|
|
1443
1473
|
if (namespaceCollision) throw new Error(namespaceCollision);
|
|
1444
1474
|
const existing = config.providers[provider];
|
|
1475
|
+
const providerConfig = def.resolveProviderConfig?.(config) ?? def.providerConfig;
|
|
1445
1476
|
// Clone operator state, including xAI wire choices and their migration version.
|
|
1446
|
-
const next: OcxProviderConfig = structuredClone(existing ??
|
|
1477
|
+
const next: OcxProviderConfig = structuredClone(existing ?? providerConfig);
|
|
1447
1478
|
for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) {
|
|
1448
|
-
const value =
|
|
1479
|
+
const value = providerConfig[field];
|
|
1449
1480
|
if (value === undefined) delete next[field];
|
|
1450
1481
|
else next[field] = structuredClone(value) as never;
|
|
1451
1482
|
}
|
|
@@ -1454,7 +1485,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
1454
1485
|
if (next.googleMode === "cloud-code-assist") delete next.project;
|
|
1455
1486
|
// Login used to rebuild the whole row from the preset, so catalog data refreshed
|
|
1456
1487
|
// immediately. Keep that timing without overwriting unrelated operator-owned fields.
|
|
1457
|
-
applyOAuthPresetCatalog(next,
|
|
1488
|
+
applyOAuthPresetCatalog(next, providerConfig);
|
|
1458
1489
|
// The original Command Code seed was an implementation-owned static catalog, not an
|
|
1459
1490
|
// operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row.
|
|
1460
1491
|
if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) {
|
|
@@ -1532,8 +1563,8 @@ export async function runLogin(
|
|
|
1532
1563
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
1533
1564
|
const loadLatestConfig = deps.loadConfig ?? loadConfig;
|
|
1534
1565
|
const saveLatestConfig = deps.saveConfig ?? saveConfig;
|
|
1535
|
-
|
|
1536
|
-
|
|
1566
|
+
const preflightConfig = provider !== "chatgpt" ? loadLatestConfig() : undefined;
|
|
1567
|
+
if (preflightConfig) {
|
|
1537
1568
|
const namespaceCollision = codexAccountNamespaceProviderCollisionError(
|
|
1538
1569
|
preflightConfig.codexAccountNamespaces,
|
|
1539
1570
|
provider,
|
|
@@ -1546,7 +1577,10 @@ export async function runLogin(
|
|
|
1546
1577
|
const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined;
|
|
1547
1578
|
const previousKiroActiveId = previousKiroAccounts?.activeAccountId;
|
|
1548
1579
|
const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []);
|
|
1549
|
-
const
|
|
1580
|
+
const loginProviderConfig = preflightConfig
|
|
1581
|
+
? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig)
|
|
1582
|
+
: def.providerConfig;
|
|
1583
|
+
const rawCred = await def.login(ctrl, opts, loginProviderConfig);
|
|
1550
1584
|
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
|
|
1551
1585
|
const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
|
|
1552
1586
|
try {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** OrcaRouter browser authorization: OAuth-style consent + PKCE, yielding a durable API key. */
|
|
2
|
+
import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
|
|
3
|
+
import { generatePKCE } from "./pkce";
|
|
4
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
5
|
+
|
|
6
|
+
export const ORCAROUTER_DEFAULT_API_BASE_URL = "https://api.orcarouter.ai";
|
|
7
|
+
export const ORCAROUTER_DEFAULT_AUTH_BASE_URL = "https://www.orcarouter.ai";
|
|
8
|
+
/** Backwards-compatible name for the inference/API origin. */
|
|
9
|
+
export const ORCAROUTER_DEFAULT_BASE_URL = ORCAROUTER_DEFAULT_API_BASE_URL;
|
|
10
|
+
const ORCAROUTER_CALLBACK_PORT = 51733;
|
|
11
|
+
const ORCAROUTER_CALLBACK_PATH = "/callback";
|
|
12
|
+
const ORCAROUTER_KEY_PREFIX = "sk-orca-";
|
|
13
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
export interface OrcaRouterLoginOptions {
|
|
16
|
+
/** Inference base URL. A non-public value also acts as the auth origin for one-origin self-hosting. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Optional dedicated auth origin; the public service defaults to www.orcarouter.ai. */
|
|
19
|
+
authBaseUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface OrcaRouterKeyPayload {
|
|
23
|
+
key?: unknown;
|
|
24
|
+
user_id?: unknown;
|
|
25
|
+
scope?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requestSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
29
|
+
const timeout = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
30
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the one configurable OrcaRouter origin used by both auth and inference.
|
|
35
|
+
* Plain HTTP is accepted only on loopback so a long-lived key is never sent over a
|
|
36
|
+
* clear-text remote connection by a typo in `ORCAROUTER_BASE_URL`.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeOrcaRouterBaseUrl(raw = ORCAROUTER_DEFAULT_BASE_URL): string {
|
|
39
|
+
let parsed: URL;
|
|
40
|
+
try {
|
|
41
|
+
parsed = new URL(raw.trim());
|
|
42
|
+
} catch {
|
|
43
|
+
// Do not echo malformed input: it may contain credentials pasted into the URL.
|
|
44
|
+
throw new Error("OrcaRouter base URL is invalid");
|
|
45
|
+
}
|
|
46
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
47
|
+
const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
48
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
|
|
49
|
+
throw new Error("OrcaRouter base URL must use HTTPS (HTTP is allowed only on loopback)");
|
|
50
|
+
}
|
|
51
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
52
|
+
throw new Error("OrcaRouter base URL must not contain credentials, a query, or a fragment");
|
|
53
|
+
}
|
|
54
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
55
|
+
if (path && path !== "/v1") {
|
|
56
|
+
throw new Error("OrcaRouter base URL path must be empty or /v1");
|
|
57
|
+
}
|
|
58
|
+
return parsed.origin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function orcaRouterInferenceBaseUrl(raw?: string): string {
|
|
62
|
+
return `${normalizeOrcaRouterBaseUrl(raw)}/v1`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function orcaRouterAuthBaseUrl(apiBaseUrl?: string, authBaseUrl?: string): string {
|
|
66
|
+
if (authBaseUrl) return normalizeOrcaRouterBaseUrl(authBaseUrl);
|
|
67
|
+
const apiOrigin = normalizeOrcaRouterBaseUrl(apiBaseUrl);
|
|
68
|
+
return apiOrigin === ORCAROUTER_DEFAULT_API_BASE_URL
|
|
69
|
+
? ORCAROUTER_DEFAULT_AUTH_BASE_URL
|
|
70
|
+
: apiOrigin;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseKeyPayload(value: unknown): OAuthCredentials {
|
|
74
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
75
|
+
throw new Error("OrcaRouter key exchange returned an invalid response");
|
|
76
|
+
}
|
|
77
|
+
const payload = value as OrcaRouterKeyPayload;
|
|
78
|
+
const key = typeof payload.key === "string" ? payload.key.trim() : "";
|
|
79
|
+
if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) {
|
|
80
|
+
throw new Error("OrcaRouter key exchange did not return a valid API key");
|
|
81
|
+
}
|
|
82
|
+
// The documented key/user_id response omits scope. If supplied, it must match
|
|
83
|
+
// the api scope requested by this PKCE flow.
|
|
84
|
+
if (payload.scope !== undefined && payload.scope !== "api") {
|
|
85
|
+
throw new Error("OrcaRouter key exchange did not grant the required api scope");
|
|
86
|
+
}
|
|
87
|
+
const accountId = typeof payload.user_id === "string"
|
|
88
|
+
? payload.user_id.trim()
|
|
89
|
+
: typeof payload.user_id === "number" && Number.isSafeInteger(payload.user_id)
|
|
90
|
+
? String(payload.user_id)
|
|
91
|
+
: "";
|
|
92
|
+
if (!accountId || accountId.length > 256 || /[\x00-\x1f\x7f]/.test(accountId)) {
|
|
93
|
+
throw new Error("OrcaRouter key exchange did not return a valid user id");
|
|
94
|
+
}
|
|
95
|
+
// OrcaRouter issues a normal long-lived API key, not a refresh token. The OAuth
|
|
96
|
+
// store requires both fields, so mirror the established Command Code key-grant
|
|
97
|
+
// representation. `expires` prevents background refresh; an upstream 401 asks the
|
|
98
|
+
// user to reconnect and mint a replacement key.
|
|
99
|
+
return {
|
|
100
|
+
access: key,
|
|
101
|
+
refresh: key,
|
|
102
|
+
expires: Number.MAX_SAFE_INTEGER,
|
|
103
|
+
accountId,
|
|
104
|
+
source: "oauth",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertDurableApiKey(apiKey: string): void {
|
|
109
|
+
const key = apiKey.trim();
|
|
110
|
+
if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) {
|
|
111
|
+
throw new Error("OrcaRouter API key is invalid; reconnect with ocx login orcarouter-oauth");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
|
|
116
|
+
readonly #authBaseUrl: string;
|
|
117
|
+
#verifier = "";
|
|
118
|
+
|
|
119
|
+
constructor(ctrl: OAuthController, options: OrcaRouterLoginOptions = {}) {
|
|
120
|
+
super(ctrl, {
|
|
121
|
+
preferredPort: ORCAROUTER_CALLBACK_PORT,
|
|
122
|
+
callbackPath: ORCAROUTER_CALLBACK_PATH,
|
|
123
|
+
callbackHostname: "127.0.0.1",
|
|
124
|
+
callbackBindHostname: "127.0.0.1",
|
|
125
|
+
} satisfies OAuthCallbackFlowOptions);
|
|
126
|
+
this.#authBaseUrl = orcaRouterAuthBaseUrl(options.baseUrl, options.authBaseUrl);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions: string }> {
|
|
130
|
+
const pkce = await generatePKCE();
|
|
131
|
+
this.#verifier = pkce.verifier;
|
|
132
|
+
const url = new URL("/auth", this.#authBaseUrl);
|
|
133
|
+
url.search = new URLSearchParams({
|
|
134
|
+
callback_url: redirectUri,
|
|
135
|
+
code_challenge: pkce.challenge,
|
|
136
|
+
code_challenge_method: "S256",
|
|
137
|
+
state,
|
|
138
|
+
app_name: "OpenCodex",
|
|
139
|
+
scope: "api",
|
|
140
|
+
}).toString();
|
|
141
|
+
return {
|
|
142
|
+
url: url.toString(),
|
|
143
|
+
instructions:
|
|
144
|
+
"Approve access in your browser. If the browser cannot reach this machine, choose the displayed-code option and paste the code here.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async exchangeToken(code: string, _state: string, _redirectUri: string): Promise<OAuthCredentials> {
|
|
149
|
+
if (!this.#verifier) throw new Error("OrcaRouter PKCE verifier was not initialized");
|
|
150
|
+
let response: Response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(new URL("/api/v1/auth/keys", this.#authBaseUrl), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
code,
|
|
157
|
+
code_verifier: this.#verifier,
|
|
158
|
+
code_challenge_method: "S256",
|
|
159
|
+
}),
|
|
160
|
+
redirect: "error",
|
|
161
|
+
signal: requestSignal(this.ctrl.signal),
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (this.ctrl.signal?.aborted) {
|
|
165
|
+
throw this.ctrl.signal.reason ?? new DOMException("OrcaRouter login aborted", "AbortError");
|
|
166
|
+
}
|
|
167
|
+
throw new Error("OrcaRouter key exchange failed: network error", { cause: error });
|
|
168
|
+
}
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
// The body is deliberately not reflected: authentication error payloads must
|
|
171
|
+
// never turn a code, verifier, or accidentally returned key into console output.
|
|
172
|
+
throw new Error(`OrcaRouter key exchange failed with HTTP ${response.status}`);
|
|
173
|
+
}
|
|
174
|
+
let payload: unknown;
|
|
175
|
+
try {
|
|
176
|
+
payload = await response.json();
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error("OrcaRouter key exchange returned invalid JSON");
|
|
179
|
+
}
|
|
180
|
+
return parseKeyPayload(payload);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function loginOrcaRouter(
|
|
185
|
+
ctrl: OAuthController,
|
|
186
|
+
options: OrcaRouterLoginOptions = {},
|
|
187
|
+
): Promise<OAuthCredentials> {
|
|
188
|
+
if (ctrl.signal?.aborted) {
|
|
189
|
+
throw ctrl.signal.reason ?? new DOMException("OrcaRouter login aborted", "AbortError");
|
|
190
|
+
}
|
|
191
|
+
return new OrcaRouterOAuthFlow(ctrl, options).login();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function refreshOrcaRouterKey(apiKey: string): Promise<never> {
|
|
195
|
+
assertDurableApiKey(apiKey);
|
|
196
|
+
// This hook is reached only after upstream rejected the durable key. There is no refresh
|
|
197
|
+
// grant to replay, so classify the credential as terminal and let the shared generation-safe
|
|
198
|
+
// refresh path mark this exact account as needing a new browser login.
|
|
199
|
+
throw new Error("invalid_grant: OrcaRouter API keys cannot be refreshed; reconnect with ocx login orcarouter-oauth");
|
|
200
|
+
}
|
|
@@ -64,6 +64,16 @@ function keychainAccount(reference: string): string {
|
|
|
64
64
|
return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A reference belongs to `name` only when its account is that provider's own active account
|
|
69
|
+
* or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes,
|
|
70
|
+
* so anything else in a provider's config names another provider's secret.
|
|
71
|
+
*/
|
|
72
|
+
function keychainReferenceBelongsToProvider(reference: string, name: string): boolean {
|
|
73
|
+
const account = keychainAccount(reference);
|
|
74
|
+
return account === name || account.startsWith(`${name}/`);
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
function readKeychain(account: string): string | undefined {
|
|
68
78
|
const cached = resolvedCache.get(account);
|
|
69
79
|
if (cached !== undefined) return cached;
|
|
@@ -185,6 +195,18 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string):
|
|
|
185
195
|
const pool = provider.apiKeyPool ?? [];
|
|
186
196
|
const resolved = new Map<string, string>();
|
|
187
197
|
const refs = [provider.apiKey, ...pool.map(e => e.key)].filter(isKeychainReference);
|
|
198
|
+
// Restore reads a secret out of the keychain, writes it back to config as plaintext, and then
|
|
199
|
+
// DELETES the keychain item. Following a reference to another provider's account would both
|
|
200
|
+
// disclose that secret through this provider's config and destroy the real owner's credential,
|
|
201
|
+
// so refuse before anything is read or removed.
|
|
202
|
+
const foreign = refs.filter(ref => !keychainReferenceBelongsToProvider(ref, name));
|
|
203
|
+
if (foreign.length > 0) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: `provider "${name}" references a keychain account it does not own (${foreign.length} reference(s)); config left unchanged`,
|
|
207
|
+
status: 400,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
188
210
|
for (const ref of refs) {
|
|
189
211
|
const account = keychainAccount(ref);
|
|
190
212
|
if (resolved.has(account)) continue;
|
|
@@ -1122,6 +1122,45 @@ const CLINE_PASS_MODELS = [
|
|
|
1122
1122
|
"cline-pass/qwen3.7-max",
|
|
1123
1123
|
"cline-pass/qwen3.7-plus",
|
|
1124
1124
|
];
|
|
1125
|
+
|
|
1126
|
+
const ORCAROUTER_MODEL_DISCOVERY: ProviderModelDiscoverySpec = {
|
|
1127
|
+
path: "models",
|
|
1128
|
+
query: { capability: "chat" },
|
|
1129
|
+
maxResponseBytes: 512 * 1024,
|
|
1130
|
+
maxModels: 512,
|
|
1131
|
+
filter: {
|
|
1132
|
+
anyOf: [{
|
|
1133
|
+
path: ["supported_endpoint_types"],
|
|
1134
|
+
containsAny: ["openai", "openai-response", "anthropic", "gemini"],
|
|
1135
|
+
caseInsensitive: true,
|
|
1136
|
+
}],
|
|
1137
|
+
noneOf: [{
|
|
1138
|
+
path: ["supported_endpoint_types"],
|
|
1139
|
+
containsAny: ["image-generation", "openai-video", "jina-rerank"],
|
|
1140
|
+
caseInsensitive: true,
|
|
1141
|
+
}],
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
// Preserve the previously verified cold-start catalog. Live discovery remains authoritative
|
|
1145
|
+
// when it succeeds, but a temporary catalog outage must not erase the provider's known-good
|
|
1146
|
+
// selectors from the picker. `orcarouter/auto` is intentionally retained here even though the
|
|
1147
|
+
// public catalog did not enumerate it at the latest verification (2026-09-07).
|
|
1148
|
+
const ORCAROUTER_MODELS = [
|
|
1149
|
+
"openai/gpt-5.5",
|
|
1150
|
+
"anthropic/claude-opus-4.8",
|
|
1151
|
+
"google/gemini-3.5-flash",
|
|
1152
|
+
"deepseek/deepseek-v4-pro",
|
|
1153
|
+
"orcarouter/auto",
|
|
1154
|
+
];
|
|
1155
|
+
const ORCAROUTER_TEXT_ONLY_MODELS = ["deepseek/deepseek-v4-pro"];
|
|
1156
|
+
const ORCAROUTER_MODEL_REASONING_EFFORTS = {
|
|
1157
|
+
// Live /models currently exposes ids and modalities, not the accepted reasoning ladder.
|
|
1158
|
+
"openai/gpt-5.5": ["low", "medium", "high", "xhigh"],
|
|
1159
|
+
"deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"),
|
|
1160
|
+
};
|
|
1161
|
+
const ORCAROUTER_MODEL_REASONING_EFFORT_MAP = {
|
|
1162
|
+
"deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro"),
|
|
1163
|
+
};
|
|
1125
1164
|
const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
1126
1165
|
"cline-pass/glm-5.3": 1_048_576,
|
|
1127
1166
|
"cline-pass/glm-5.3-flash": 1_048_576,
|
|
@@ -1360,6 +1399,25 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1360
1399
|
// The proprietary generate wire has no verified per-request serialization flag.
|
|
1361
1400
|
parallelToolCalls: false,
|
|
1362
1401
|
},
|
|
1402
|
+
{
|
|
1403
|
+
id: "orcarouter-oauth",
|
|
1404
|
+
label: "OrcaRouter - Auth",
|
|
1405
|
+
adapter: "openai-chat",
|
|
1406
|
+
baseUrl: "https://api.orcarouter.ai/v1",
|
|
1407
|
+
authKind: "oauth",
|
|
1408
|
+
oauthId: "orcarouter-oauth",
|
|
1409
|
+
featured: true,
|
|
1410
|
+
allowBaseUrlOverride: true,
|
|
1411
|
+
defaultModel: "openai/gpt-5.5",
|
|
1412
|
+
models: ORCAROUTER_MODELS,
|
|
1413
|
+
liveModels: true,
|
|
1414
|
+
modelDiscovery: ORCAROUTER_MODEL_DISCOVERY,
|
|
1415
|
+
noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS,
|
|
1416
|
+
modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS,
|
|
1417
|
+
modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP,
|
|
1418
|
+
preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS,
|
|
1419
|
+
note: "Connect your OrcaRouter account with OAuth 2.0 + PKCE; the issued API key is stored in OpenCodex's existing credential store.",
|
|
1420
|
+
},
|
|
1363
1421
|
{
|
|
1364
1422
|
id: "anthropic",
|
|
1365
1423
|
label: "Anthropic Claude",
|
|
@@ -1834,37 +1892,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1834
1892
|
note: "Cline usage-billing API: one key, 100+ models, OpenRouter-style ids. Promotional free models are IDE/CLI-only per Cline docs; minimax/minimax-m2.5 is the documented API free experimentation model.",
|
|
1835
1893
|
},
|
|
1836
1894
|
{
|
|
1837
|
-
// OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai).
|
|
1838
|
-
//
|
|
1839
|
-
|
|
1840
|
-
// selectable. Live-verified 2026-07-20: /v1/chat/completions accepts the `tools` field
|
|
1841
|
-
// and routes to a function-calling-capable upstream.
|
|
1842
|
-
id: "orcarouter", label: "OrcaRouter", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1",
|
|
1895
|
+
// OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). The public live
|
|
1896
|
+
// catalog is authoritative; model ids and input modalities are never maintained here.
|
|
1897
|
+
id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1",
|
|
1843
1898
|
authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console",
|
|
1899
|
+
// The catalog is public, so a successful /models probe cannot validate a submitted key.
|
|
1900
|
+
apiKeyValidation: "unknown",
|
|
1844
1901
|
defaultModel: "openai/gpt-5.5",
|
|
1845
|
-
models:
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
// - openai/gpt-5.5 accepts reasoning_effort none|low|medium|high|xhigh but rejects `max` (400),
|
|
1856
|
-
// so advertise up to xhigh and let mapReasoningEffort clamp a `max`/`ultra` request to xhigh.
|
|
1857
|
-
// - deepseek/deepseek-v4-pro mirrors the direct-DeepSeek wiring (thinking-effort map +
|
|
1858
|
-
// reasoning_content history replay) so the namespaced selection behaves identically.
|
|
1859
|
-
// - temperature is accepted by every seeded model (gpt-5.5, claude-opus-4.8, deepseek-v4-pro all
|
|
1860
|
-
// returned 200), so no noTemperatureModels entry is warranted here.
|
|
1861
|
-
modelReasoningEfforts: {
|
|
1862
|
-
"openai/gpt-5.5": ["low", "medium", "high", "xhigh"],
|
|
1863
|
-
"deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"),
|
|
1864
|
-
},
|
|
1865
|
-
modelReasoningEffortMap: { "deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro") },
|
|
1866
|
-
preserveReasoningContentModels: ["deepseek/deepseek-v4-pro"],
|
|
1867
|
-
note: "OpenAI-compatible adaptive router. Default is a tool-capable model; orcarouter/auto (adaptive routing) is also selectable. Full catalog: https://www.orcarouter.ai/models",
|
|
1902
|
+
models: ORCAROUTER_MODELS,
|
|
1903
|
+
liveModels: true,
|
|
1904
|
+
modelDiscovery: ORCAROUTER_MODEL_DISCOVERY,
|
|
1905
|
+
// Catalog discovery owns WHICH models exist. These entries only retain verified
|
|
1906
|
+
// request-shaping facts that the upstream catalog does not currently publish.
|
|
1907
|
+
noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS,
|
|
1908
|
+
modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS,
|
|
1909
|
+
modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP,
|
|
1910
|
+
preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS,
|
|
1911
|
+
note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.",
|
|
1868
1912
|
},
|
|
1869
1913
|
{
|
|
1870
1914
|
// BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are
|
|
@@ -3081,6 +3125,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
3081
3125
|
"gpt-5.6-luna": "openai-responses",
|
|
3082
3126
|
"gpt-5.6-sol": "openai-responses",
|
|
3083
3127
|
"gpt-5.6-terra": "openai-responses",
|
|
3128
|
+
"gpt-6-astra": "openai-responses",
|
|
3129
|
+
"grok-4.5": "openai-responses",
|
|
3130
|
+
"grok-4.6": "openai-responses",
|
|
3131
|
+
"mai-code-1.1-flash": "openai-responses",
|
|
3132
|
+
"mai-code-1-flash-picker": "openai-responses",
|
|
3084
3133
|
},
|
|
3085
3134
|
note: "Experimental unofficial Copilot bridge. Logs in via GitHub device flow using the public VS Code OAuth client id, then exchanges for a short-lived Copilot API token (copilot_internal). Requires an active Copilot subscription. GitHub may tighten or revoke this path; do not send confidential material you would not paste into Copilot Chat.",
|
|
3086
3135
|
},
|