@bitkyc08/opencodex 2.7.30 → 2.7.33-preview.20260722

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.
Files changed (59) hide show
  1. package/README.ja.md +438 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +480 -0
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +18 -1
  7. package/gui/dist/assets/index-B79f-04T.js +52 -0
  8. package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/anthropic.ts +17 -2
  12. package/src/adapters/cursor/discovery.ts +2 -2
  13. package/src/adapters/google-tool-schema.ts +4 -0
  14. package/src/adapters/google.ts +17 -2
  15. package/src/adapters/openai-chat.ts +36 -1
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +12 -4
  18. package/src/cli/account-api.ts +4 -2
  19. package/src/cli/account-extended.ts +34 -0
  20. package/src/cli/account.ts +3 -1
  21. package/src/cli/claude.ts +6 -1
  22. package/src/cli/help.ts +25 -4
  23. package/src/cli/init.ts +38 -3
  24. package/src/cli/models.ts +206 -7
  25. package/src/codex/auth-api.ts +45 -3
  26. package/src/codex/catalog.ts +105 -12
  27. package/src/codex/routing.ts +85 -4
  28. package/src/combos/index.ts +1 -1
  29. package/src/combos/request.ts +26 -4
  30. package/src/combos/types.ts +4 -4
  31. package/src/config.ts +60 -3
  32. package/src/lib/upstream-retry.ts +21 -0
  33. package/src/lib/winsw.ts +7 -1
  34. package/src/oauth/anthropic.ts +23 -1
  35. package/src/oauth/github-copilot.ts +1 -0
  36. package/src/oauth/index.ts +88 -8
  37. package/src/oauth/kiro.ts +12 -1
  38. package/src/oauth/local-token-detect.ts +3 -0
  39. package/src/oauth/store.ts +43 -0
  40. package/src/oauth/types.ts +3 -1
  41. package/src/providers/antigravity-models.ts +123 -14
  42. package/src/providers/api-keys.ts +12 -0
  43. package/src/providers/openrouter-routing.ts +102 -0
  44. package/src/providers/registry.ts +41 -17
  45. package/src/router.ts +2 -1
  46. package/src/server/auth-cors.ts +5 -0
  47. package/src/server/index.ts +3 -3
  48. package/src/server/management-api.ts +143 -6
  49. package/src/server/relay.ts +31 -5
  50. package/src/server/request-log.ts +12 -0
  51. package/src/server/responses.ts +127 -31
  52. package/src/service.ts +12 -3
  53. package/src/types.ts +44 -3
  54. package/src/update/index.ts +19 -2
  55. package/src/update/job.ts +13 -3
  56. package/src/usage/expected-prices.ts +22 -9
  57. package/src/usage/summary.ts +43 -0
  58. package/gui/dist/assets/index-B-UauL1p.css +0 -1
  59. package/gui/dist/assets/index-avcinRsG.js +0 -40
@@ -36,6 +36,38 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string
36
36
  const accountHash = createHash("sha256").update(accountId).digest("hex").slice(0, 24);
37
37
  return join(getConfigDir(), `auth.refresh.${safeProvider}.${accountHash}.lock`);
38
38
  }
39
+ export function getAuthRefreshIntentPath(provider: string, accountId: string): string {
40
+ return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
41
+ }
42
+ export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; uncertain?: true }
43
+ export function readOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent | undefined {
44
+ const path = getAuthRefreshIntentPath(provider, accountId);
45
+ try {
46
+ hardenConfigDir();
47
+ hardenExistingSecret(path);
48
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<OAuthRefreshIntent>;
49
+ if (value.version !== 1 || value.provider !== provider || value.accountId !== accountId || typeof value.generation !== "string" || typeof value.createdAt !== "number") {
50
+ return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
51
+ }
52
+ return value as OAuthRefreshIntent;
53
+ } catch (error) {
54
+ if (errorCode(error) === "ENOENT") return undefined;
55
+ return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
56
+ }
57
+ }
58
+ export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now()): void {
59
+ const dir = getConfigDir();
60
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
61
+ hardenConfigDir();
62
+ const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt };
63
+ atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
64
+ }
65
+ export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean {
66
+ const current = readOAuthRefreshIntent(provider, accountId);
67
+ if (!current || current.generation !== generation) return false;
68
+ try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; }
69
+ catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; }
70
+ }
39
71
  export function credentialGeneration(cred: OAuthCredentials): string {
40
72
  return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
41
73
  }
@@ -147,6 +179,7 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
147
179
  const credential = normalizeCredential(candidate.credential);
148
180
  if (!credential) return null;
149
181
  const account: ProviderAccount = { id: candidate.id, credential };
182
+ if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim();
150
183
  if (candidate.needsReauth === true) account.needsReauth = true;
151
184
  if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
152
185
  return account;
@@ -309,6 +342,16 @@ export async function setActiveAccount(provider: string, accountId: string): Pro
309
342
  });
310
343
  }
311
344
 
345
+ export async function setAccountAlias(provider: string, accountId: string, alias: string | undefined): Promise<boolean> {
346
+ return await mutateStore(store => {
347
+ const account = store[provider]?.accounts.find(a => a.id === accountId);
348
+ if (!account) return false;
349
+ if (alias) account.alias = alias;
350
+ else delete account.alias;
351
+ return true;
352
+ });
353
+ }
354
+
312
355
  /** Remove one account by id; active removal promotes the first remaining account. */
313
356
  export async function removeAccount(provider: string, accountId: string): Promise<boolean> {
314
357
  return await mutateStore(store => {
@@ -21,6 +21,8 @@ export type OAuthCredentials = {
21
21
  export interface ProviderAccount {
22
22
  /** Stable short id, generated once at append time; never re-derived after rotation. */
23
23
  id: string;
24
+ /** User-owned display label; never participates in auth identity or routing. */
25
+ alias?: string;
24
26
  credential: OAuthCredentials;
25
27
  /** Terminal refresh failure (invalid_grant / reused / revoked) — re-login required. */
26
28
  needsReauth?: boolean;
@@ -34,7 +36,7 @@ export interface ProviderAccountSet {
34
36
  }
35
37
 
36
38
  export interface OAuthController {
37
- onAuth?(info: { url: string; instructions?: string }): void;
39
+ onAuth?(info: { url: string; instructions?: string; deviceCode?: string }): void;
38
40
  onProgress?(message: string): void;
39
41
  onManualCodeInput?(expectedState?: string): Promise<string>;
40
42
  signal?: AbortSignal;
@@ -1,13 +1,16 @@
1
1
  // Google Antigravity (Cloud Code Assist) bundled model list.
2
2
  //
3
3
  // Single source of truth: the Antigravity `:fetchAvailableModels` backend, the same one the `agy`
4
- // CLI resolves labels against. The ids below are the CCA WIRE ids followed by CLIProxyAPI-style
5
- // client aliases. The CCA envelope's `model` field must receive the wire id (for example
6
- // "Gemini 3.1 Pro (High)" => gemini-pro-agent), while the picker can expose label-shaped aliases.
4
+ // CLI resolves labels against. The ids below separate CCA wire ids, collapsed picker entries,
5
+ // and hidden compatibility aliases for saved selections. The CCA envelope's `model` field must
6
+ // receive the wire id (for example "Gemini 3.1 Pro (High)" => gemini-pro-agent), while the
7
+ // picker exposes collapsed base models with reasoning-effort routing.
8
+
9
+ // ── Wire IDs (what CCA :fetchAvailableModels returns) ──
7
10
  const ANTIGRAVITY_WIRE_MODELS = [
8
- "gemini-3.5-flash-low",
9
- "gemini-3-flash-agent",
10
- "gemini-3.5-flash-extra-low",
11
+ "gemini-3.6-flash-low",
12
+ "gemini-3.6-flash-medium",
13
+ "gemini-3.6-flash-high",
11
14
  "gemini-3.1-pro-low",
12
15
  "gemini-pro-agent",
13
16
  "claude-sonnet-4-6",
@@ -15,23 +18,76 @@ const ANTIGRAVITY_WIRE_MODELS = [
15
18
  "gpt-oss-120b-medium",
16
19
  ];
17
20
 
18
- export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
19
- "gemini-3.5-flash-mid": "gemini-3.5-flash-low",
20
- "gemini-3.5-flash-high": "gemini-3-flash-agent",
21
+ // ── Effort ladders per collapsed base model ──
22
+ // Gemini models: effort → wire model suffix (official agy UI pattern).
23
+ // Claude Opus: effort → thinkingConfig.thinkingLevel (CLIProxyAPI proven pattern).
24
+ export const ANTIGRAVITY_MODEL_EFFORTS: Record<string, string[]> = {
25
+ "gemini-3.6-flash": ["low", "medium", "high"],
26
+ "gemini-3.1-pro": ["low", "high"],
27
+ "claude-sonnet-4-6": ["low", "medium", "high", "max"],
28
+ "claude-opus-4-6-thinking": ["low", "medium", "high", "max"],
29
+ };
30
+
31
+ // ── Effort → wire model map for Gemini base models ──
32
+ const ANTIGRAVITY_EFFORT_WIRE_MAP: Record<string, Record<string, string>> = {
33
+ "gemini-3.6-flash": {
34
+ low: "gemini-3.6-flash-low",
35
+ medium: "gemini-3.6-flash-medium",
36
+ high: "gemini-3.6-flash-high",
37
+ },
38
+ "gemini-3.1-pro": {
39
+ low: "gemini-3.1-pro-low",
40
+ high: "gemini-pro-agent",
41
+ },
42
+ };
43
+
44
+ // ── Default effort per Gemini base model ──
45
+ const ANTIGRAVITY_DEFAULT_EFFORT: Record<string, string> = {
46
+ "gemini-3.6-flash": "medium",
47
+ "gemini-3.1-pro": "high",
48
+ };
49
+
50
+ // ── Visible client aliases (kept for saved-config compat, not picker-visible) ──
51
+ const ANTIGRAVITY_VISIBLE_MODEL_ALIASES: Record<string, string> = {
21
52
  "gemini-3.1-pro-high": "gemini-pro-agent",
22
53
  "gemini-3.1-pro-preview": "gemini-pro-agent",
23
54
  };
24
55
 
56
+ // ── Hidden compatibility aliases for saved selections ──
57
+ // Wire suffix IDs are identity aliases — they resolve to themselves so saved configs
58
+ // with explicit suffixes (e.g. gemini-3.6-flash-low) continue to work.
59
+ const ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES: Record<string, string> = {
60
+ "gemini-3.6-flash-low": "gemini-3.6-flash-low",
61
+ "gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
62
+ "gemini-3.6-flash-high": "gemini-3.6-flash-high",
63
+ "gemini-3.1-pro-low": "gemini-3.1-pro-low",
64
+ "gemini-pro-agent": "gemini-pro-agent",
65
+ "gemini-3.5-flash-extra-low": "gemini-3.6-flash-low",
66
+ "gemini-3.5-flash-low": "gemini-3.6-flash-medium",
67
+ "gemini-3.5-flash-mid": "gemini-3.6-flash-medium",
68
+ "gemini-3.5-flash-high": "gemini-3.6-flash-high",
69
+ "gemini-3-flash-agent": "gemini-3.6-flash-high",
70
+ };
71
+
72
+ export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
73
+ ...ANTIGRAVITY_VISIBLE_MODEL_ALIASES,
74
+ ...ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES,
75
+ };
76
+
77
+ // Picker-visible: collapsed base models only.
25
78
  export const ANTIGRAVITY_MODELS = [
26
- ...ANTIGRAVITY_WIRE_MODELS,
27
- ...Object.keys(ANTIGRAVITY_MODEL_ALIASES),
79
+ "gemini-3.6-flash",
80
+ "gemini-3.1-pro",
81
+ "claude-sonnet-4-6",
82
+ "claude-opus-4-6-thinking",
83
+ "gpt-oss-120b-medium",
28
84
  ];
29
85
 
30
86
  // Context windows from the upstream `:fetchAvailableModels` maxTokens per model.
31
87
  const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
32
- "gemini-3.5-flash-low": 1_048_576,
33
- "gemini-3-flash-agent": 1_048_576,
34
- "gemini-3.5-flash-extra-low": 1_048_576,
88
+ "gemini-3.6-flash-low": 1_048_576,
89
+ "gemini-3.6-flash-medium": 1_048_576,
90
+ "gemini-3.6-flash-high": 1_048_576,
35
91
  "gemini-3.1-pro-low": 1_048_576,
36
92
  "gemini-pro-agent": 1_048_576,
37
93
  "claude-sonnet-4-6": 200_000,
@@ -40,6 +96,10 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
40
96
  };
41
97
 
42
98
  export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
99
+ // Collapsed base IDs — explicit entries for the picker.
100
+ "gemini-3.6-flash": 1_048_576,
101
+ "gemini-3.1-pro": 1_048_576,
102
+ // Wire IDs and aliases via derivation.
43
103
  ...ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS,
44
104
  ...Object.fromEntries(
45
105
  Object.entries(ANTIGRAVITY_MODEL_ALIASES).map(([alias, wire]) => [
@@ -52,3 +112,52 @@ export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
52
112
  export function resolveAntigravityWireModelId(modelId: string): string {
53
113
  return ANTIGRAVITY_MODEL_ALIASES[modelId] ?? modelId;
54
114
  }
115
+
116
+ /**
117
+ * Whether the given model ID is a suffix wire ID or compat alias that already encodes
118
+ * an effort level. For these IDs, the caller must NOT send thinkingConfig — the suffix
119
+ * IS the effort, and sending both creates a contradictory request.
120
+ */
121
+ export function isAntigravitySuffixModelId(modelId: string): boolean {
122
+ return !(ANTIGRAVITY_MODELS as string[]).includes(modelId);
123
+ }
124
+
125
+ /**
126
+ * Resolve a picker-visible base model + optional reasoning effort to the CCA wire model ID.
127
+ *
128
+ * Precedence (evaluated in order):
129
+ * 1. Suffix wire ID or compat alias → resolve via `resolveAntigravityWireModelId`, no thinkingConfig.
130
+ * 2. Mapped Gemini base with effort → return mapped wire ID + thinkingLevel.
131
+ * 3. Mapped Gemini base without effort → return default-effort wire ID, no thinkingConfig.
132
+ * 4. Claude Opus with effort → return identity + thinkingLevel (no suffix variants exist).
133
+ * 5. All other IDs → return `resolveAntigravityWireModelId(modelId)`, no thinkingConfig.
134
+ */
135
+ export function resolveAntigravityEffortWireModel(
136
+ modelId: string,
137
+ effort?: string,
138
+ ): { wireModelId: string; thinkingLevel?: string } {
139
+ // Rule 1: suffix/compat alias — suffix IS the effort.
140
+ if (isAntigravitySuffixModelId(modelId)) {
141
+ return { wireModelId: resolveAntigravityWireModelId(modelId) };
142
+ }
143
+
144
+ // Rule 2/3: mapped Gemini base model.
145
+ const effortMap = ANTIGRAVITY_EFFORT_WIRE_MAP[modelId];
146
+ if (effortMap) {
147
+ if (effort && effort in effortMap) {
148
+ return { wireModelId: effortMap[effort]!, thinkingLevel: effort };
149
+ }
150
+ const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId]!;
151
+ return { wireModelId: effortMap[defaultEffort]! };
152
+ }
153
+
154
+ // Rule 4: Claude models — effort via thinkingConfig only (no suffix variants).
155
+ // Anthropic adaptive thinking supports low/medium/high/max for both Sonnet 4.6 and Opus 4.6.
156
+ // CLIProxyAPI proves CCA accepts thinkingConfig on base IDs (validation confirmed).
157
+ if (/^claude-/.test(modelId) && effort) {
158
+ return { wireModelId: modelId, thinkingLevel: effort };
159
+ }
160
+
161
+ // Rule 5: everything else.
162
+ return { wireModelId: resolveAntigravityWireModelId(modelId) };
163
+ }
@@ -102,6 +102,18 @@ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: str
102
102
  return true;
103
103
  }
104
104
 
105
+ /** Rename a key slot without changing its id, secret, or active routing state. */
106
+ export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: string, label: string | undefined): boolean {
107
+ const provider = config.providers[name];
108
+ if (!provider || !isKeyAuthProvider(provider)) return false;
109
+ const entry = ensurePool(provider).find(e => e.id === id);
110
+ if (!entry) return false;
111
+ if (label) entry.label = label;
112
+ else delete entry.label;
113
+ saveConfig(config);
114
+ return true;
115
+ }
116
+
105
117
  /** Remove one key; removing the active one promotes the first remaining. Persists config. */
106
118
  export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
107
119
  const provider = config.providers[name];
@@ -0,0 +1,102 @@
1
+ import type { OcxProviderConfig, OpenRouterProviderRouting } from "../types";
2
+
3
+ const ROUTING_KEYS = new Set(["order", "only", "allowFallbacks"]);
4
+ const MAX_PROVIDER_SLUGS = 64;
5
+
6
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
8
+ const prototype = Object.getPrototypeOf(value);
9
+ return prototype === Object.prototype || prototype === null;
10
+ }
11
+
12
+ export function isCanonicalOpenRouterTarget(baseUrl: string): boolean {
13
+ try {
14
+ const url = new URL(baseUrl);
15
+ return url.origin === "https://openrouter.ai"
16
+ && !url.username
17
+ && !url.password
18
+ && !url.search
19
+ && !url.hash
20
+ && url.pathname.replace(/\/+$/, "") === "/api/v1";
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function routingPreferenceError(value: unknown, field: string): string | null {
27
+ if (!isPlainRecord(value)) return `${field} must be a plain object`;
28
+ const unknown = Object.keys(value).find(key => !ROUTING_KEYS.has(key));
29
+ if (unknown) return `${field} contains unknown field "${unknown}"`;
30
+
31
+ for (const listField of ["order", "only"] as const) {
32
+ const list = value[listField];
33
+ if (list === undefined) continue;
34
+ if (!Array.isArray(list) || list.length === 0 || list.length > MAX_PROVIDER_SLUGS) {
35
+ return `${field}.${listField} must contain 1-${MAX_PROVIDER_SLUGS} provider slugs`;
36
+ }
37
+ const seen = new Set<string>();
38
+ for (const slug of list) {
39
+ if (typeof slug !== "string" || !slug.trim() || slug !== slug.trim() || slug.length > 128) {
40
+ return `${field}.${listField} must contain nonblank trimmed provider slugs up to 128 characters`;
41
+ }
42
+ if (seen.has(slug)) return `${field}.${listField} must not contain duplicate provider slugs`;
43
+ seen.add(slug);
44
+ }
45
+ }
46
+ if (value.allowFallbacks !== undefined && typeof value.allowFallbacks !== "boolean") {
47
+ return `${field}.allowFallbacks must be a boolean`;
48
+ }
49
+ if (value.order === undefined && value.only === undefined && value.allowFallbacks === undefined) {
50
+ return `${field} must define order, only, or allowFallbacks`;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export function openRouterRoutingConfigError(provider: OcxProviderConfig): string | null {
56
+ const hasDefault = provider.openRouterRouting !== undefined;
57
+ const hasModels = provider.modelOpenRouterRouting !== undefined;
58
+ if (!hasDefault && !hasModels) return null;
59
+ if (provider.adapter !== "openai-chat") {
60
+ return "OpenRouter routing preferences require the openai-chat adapter";
61
+ }
62
+ if (!isCanonicalOpenRouterTarget(provider.baseUrl)) {
63
+ return "OpenRouter routing preferences require the canonical https://openrouter.ai/api/v1 baseUrl";
64
+ }
65
+ if (hasDefault) {
66
+ const error = routingPreferenceError(provider.openRouterRouting, "openRouterRouting");
67
+ if (error) return error;
68
+ }
69
+ if (hasModels) {
70
+ const routes = provider.modelOpenRouterRouting;
71
+ if (!isPlainRecord(routes)) return "modelOpenRouterRouting must be a plain object";
72
+ for (const [modelId, preference] of Object.entries(routes)) {
73
+ if (!modelId.trim() || modelId !== modelId.trim()) {
74
+ return "modelOpenRouterRouting keys must be nonblank trimmed model ids";
75
+ }
76
+ const error = routingPreferenceError(preference, `modelOpenRouterRouting.${modelId}`);
77
+ if (error) return error;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ export function resolveOpenRouterRouting(
84
+ provider: OcxProviderConfig,
85
+ modelId: string,
86
+ ): OpenRouterProviderRouting | undefined {
87
+ if (!isCanonicalOpenRouterTarget(provider.baseUrl)) return undefined;
88
+ const modelRoutes = provider.modelOpenRouterRouting;
89
+ return modelRoutes && Object.hasOwn(modelRoutes, modelId)
90
+ ? modelRoutes[modelId]
91
+ : provider.openRouterRouting;
92
+ }
93
+
94
+ export function openRouterProviderPayload(
95
+ preference: OpenRouterProviderRouting,
96
+ ): Record<string, unknown> {
97
+ return {
98
+ ...(preference.order ? { order: [...preference.order] } : {}),
99
+ ...(preference.only ? { only: [...preference.only] } : {}),
100
+ ...(preference.allowFallbacks !== undefined ? { allow_fallbacks: preference.allowFallbacks } : {}),
101
+ };
102
+ }
@@ -1,6 +1,6 @@
1
1
  import type { CodexAccountMode, OcxProviderConfig } from "../types";
2
2
  import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models";
3
- import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS } from "./antigravity-models";
3
+ import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS } from "./antigravity-models";
4
4
  import type { ProviderBaseUrlChoice } from "./base-url-choices";
5
5
  import {
6
6
  QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL,
@@ -65,6 +65,8 @@ export interface ProviderRegistryEntry {
65
65
  noPenaltyModels?: string[];
66
66
  /** Opt this provider into parallel tool calls (see OcxProviderConfig.parallelToolCalls). */
67
67
  parallelToolCalls?: boolean;
68
+ /** Opt this provider into forwarding prompt_cache_key (OpenAI-specific; strict backends reject it). */
69
+ promptCacheKey?: boolean;
68
70
  autoToolChoiceOnlyModels?: string[];
69
71
  preserveReasoningContentModels?: string[];
70
72
  thinkingToggleModels?: string[];
@@ -196,36 +198,42 @@ const ALIBABA_TOKEN_PLAN_QWEN_MODELS = [
196
198
  ];
197
199
  const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
198
200
  "qwen3.8-max-preview": ["text", "image"],
199
- "qwen3.7-max": ["text"],
201
+ "qwen3.7-max": ["text", "image"],
200
202
  "qwen3.7-plus": ["text", "image"],
201
203
  "qwen3.6-flash": ["text", "image"],
202
204
  "glm-5.2": ["text"],
203
205
  "deepseek-v4-pro": ["text"],
204
206
  };
205
207
 
206
- // 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore).
208
+ // 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore, hardened 260721).
207
209
  // Multi-vendor lineup distinct from Beijing — includes DeepSeek V4 flash, Kimi K2.7, MiniMax.
208
210
  // Evidence: https://www.alibabacloud.com/help/en/model-studio/token-plan-overview
211
+ // https://qwencloud.com/pricing/token-plan (qwen3.8 metadata)
209
212
  const ALIBABA_INTL_TOKEN_PLAN_MODELS = [
210
- "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
213
+ "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
211
214
  "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2",
212
- "kimi-k2.7-code",
213
- "glm-5.2",
215
+ "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5",
216
+ "glm-5.2", "glm-5.1", "glm-5",
214
217
  "MiniMax-M2.5",
215
218
  ];
216
219
  const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [
217
- "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
220
+ "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
218
221
  ];
219
222
  const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
220
- "qwen3.7-max": ["text"],
223
+ "qwen3.8-max-preview": ["text", "image"],
224
+ "qwen3.7-max": ["text", "image"],
221
225
  "qwen3.7-plus": ["text", "image"],
222
226
  "qwen3.6-plus": ["text", "image"],
223
227
  "qwen3.6-flash": ["text", "image"],
224
228
  "deepseek-v4-pro": ["text"],
225
229
  "deepseek-v4-flash": ["text"],
226
230
  "deepseek-v3.2": ["text"],
227
- "kimi-k2.7-code": ["text"],
231
+ "kimi-k2.7-code": ["text", "image"],
232
+ "kimi-k2.6": ["text", "image"],
233
+ "kimi-k2.5": ["text", "image"],
228
234
  "glm-5.2": ["text"],
235
+ "glm-5.1": ["text"],
236
+ "glm-5": ["text"],
229
237
  "MiniMax-M2.5": ["text"],
230
238
  };
231
239
 
@@ -632,9 +640,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
632
640
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
633
641
  {
634
642
  id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true,
635
- dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.5-flash", "gemini-3.1-pro-preview"],
636
- modelContextWindows: { "gemini-3.5-flash": 1_000_000 },
643
+ dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"],
644
+ modelContextWindows: { "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576 },
645
+ modelInputModalities: { "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"] },
637
646
  modelReasoningEfforts: {
647
+ "gemini-3.6-flash": ["minimal", "low", "medium", "high"],
638
648
  "gemini-3.5-flash": ["minimal", "low", "medium", "high"],
639
649
  "gemini-3.1-pro-preview": ["low", "medium", "high"],
640
650
  },
@@ -643,7 +653,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
643
653
  // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
644
654
  // evidence from ai.google.dev does not establish Vertex publisher availability.
645
655
  { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
646
- { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
656
+ { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
647
657
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
648
658
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
649
659
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
@@ -762,6 +772,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
762
772
  liveModels: false,
763
773
  note: "Token Plan Personal Edition · China (Beijing)",
764
774
  modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES,
775
+ modelContextWindows: {
776
+ "qwen3.8-max-preview": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000,
777
+ "qwen3.6-flash": 1_000_000, "glm-5.2": 1_000_000, "deepseek-v4-pro": 1_000_000,
778
+ },
765
779
  modelReasoningEfforts: {
766
780
  ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
767
781
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
@@ -769,7 +783,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
769
783
  },
770
784
  modelReasoningEffortMap: { "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
771
785
  thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS,
772
- preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max-preview"],
786
+ preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"],
787
+ noVisionModels: ["glm-5.2", "deepseek-v4-pro"],
773
788
  },
774
789
  {
775
790
  id: "alibaba-token-plan-intl",
@@ -786,9 +801,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
786
801
  note: "Token Plan Team Edition · Singapore (ap-southeast-1)",
787
802
  metadataModelIdNormalize: "case-insensitive",
788
803
  modelInputModalities: ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES,
789
- modelContextWindows: { "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "glm-5.2": 1_000_000 },
804
+ modelContextWindows: {
805
+ "qwen3.8-max-preview": 983_616,
806
+ "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, "qwen3.6-plus": 1_000_000, "qwen3.6-flash": 1_000_000,
807
+ "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072,
808
+ "kimi-k2.7-code": 262_144, "kimi-k2.6": 262_144, "kimi-k2.5": 262_144,
809
+ "glm-5.2": 1_000_000, "glm-5.1": 1_000_000, "glm-5": 1_000_000,
810
+ "MiniMax-M2.5": 204_800,
811
+ },
790
812
  modelReasoningEfforts: {
791
813
  ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
814
+ "qwen3.8-max-preview": ["low", "high", "xhigh"],
792
815
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
793
816
  "deepseek-v4-pro": DEEPSEEK_THINKING_EFFORTS,
794
817
  "deepseek-v4-flash": DEEPSEEK_THINKING_EFFORTS,
@@ -798,9 +821,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
798
821
  "deepseek-v4-flash": DEEPSEEK_THINKING_REASONING_MAP,
799
822
  },
800
823
  thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS,
801
- preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.7-max"],
802
- noVisionModels: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", "kimi-k2.7-code", "glm-5.2", "MiniMax-M2.5", "qwen3.7-max"],
803
- noReasoningModels: ["kimi-k2.7-code", "deepseek-v3.2", "MiniMax-M2.5"],
824
+ preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"],
825
+ noVisionModels: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
826
+ noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
827
+ modelDefaultReasoningEfforts: { "qwen3.8-max-preview": "xhigh" },
804
828
  },
805
829
  // NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL,
806
830
  // no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai.
package/src/router.ts CHANGED
@@ -182,6 +182,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
182
182
  // Scalar backfill: a persisted config created before the flag shipped inherits the registry
183
183
  // opt-in, while an explicit user `false` keeps overriding registry `true`.
184
184
  ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}),
185
+ ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}),
185
186
  ...(modelContextWindows ? { modelContextWindows } : {}),
186
187
  ...(modelInputModalities ? { modelInputModalities } : {}),
187
188
  ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}),
@@ -208,7 +209,7 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][]
208
209
 
209
210
  export class NoEnabledOpenAiProviderError extends Error {
210
211
  constructor(modelId: string) {
211
- super(`No enabled canonical OpenAI provider for model: ${modelId}`);
212
+ super(`No enabled OpenAI provider for model: ${modelId}. Run 'ocx init' to configure a provider, or check that your config has an enabled 'openai' provider.`);
212
213
  this.name = "NoEnabledOpenAiProviderError";
213
214
  }
214
215
  }
@@ -10,6 +10,7 @@ import { providerDestinationConfigError } from "../lib/destination-policy";
10
10
  import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
11
11
  import { providerConfigSeed } from "../providers/derive";
12
12
  import type { OcxConfig, OcxProviderConfig } from "../types";
13
+ import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
13
14
 
14
15
  let _corsOrigin = "http://localhost:10100";
15
16
  export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
@@ -226,6 +227,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
226
227
  if (headersError) return `provider ${name} ${headersError}`;
227
228
  const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
228
229
  if (maxInputError) return `provider ${name} ${maxInputError}`;
230
+ const openRouterError = openRouterRoutingConfigError(typed);
231
+ if (openRouterError) return `provider ${name} ${openRouterError}`;
229
232
  if (typed.authMode === "local") {
230
233
  // "local" bypasses key-requirement enforcement (api-keys/key-failover treat non-oauth/
231
234
  // forward as key auth; openai-chat skips credential checks for local). Only providers
@@ -290,6 +293,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
290
293
  "models",
291
294
  "contextWindow",
292
295
  "modelContextWindows",
296
+ "openRouterRouting",
297
+ "modelOpenRouterRouting",
293
298
  "reasoningEfforts",
294
299
  "modelReasoningEfforts",
295
300
  "noVisionModels",
@@ -537,7 +537,7 @@ export function startServer(port?: number) {
537
537
  void (async () => {
538
538
  const start = Date.now();
539
539
  const requestId = nextRequestLogId(start);
540
- const logCtx = { model: "unknown", provider: "unknown" };
540
+ const logCtx: RequestLogContext = { model: "unknown", provider: "unknown" };
541
541
  let logged = false;
542
542
  const finalizeLog = (
543
543
  status: number,
@@ -556,7 +556,7 @@ export function startServer(port?: number) {
556
556
  body: JSON.stringify({ ...payload, stream: true }),
557
557
  });
558
558
  try {
559
- let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
559
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
560
560
  const response = await handleResponses(req, config, logCtx, {
561
561
  forceEmptyResponseId: true,
562
562
  abortSignal: turnAbort.signal,
@@ -570,7 +570,7 @@ export function startServer(port?: number) {
570
570
  await sendResponseToWebSocket(ws, response, isCurrent, {
571
571
  onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
572
572
  onTerminal: status => {
573
- terminalRecorder?.(status);
573
+ terminalRecorder?.(status, logCtx.terminalHttpStatus);
574
574
  finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
575
575
  terminalStatus: status,
576
576
  closeReason: "terminal",