@bitkyc08/opencodex 2.7.31 → 2.7.33

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 (46) 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 +2 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +18 -1
  7. package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
  8. package/gui/dist/assets/index-d63HMU0x.js +52 -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/google-tool-schema.ts +4 -0
  13. package/src/adapters/google.ts +10 -2
  14. package/src/adapters/openai-chat.ts +36 -1
  15. package/src/adapters/openai-responses.ts +2 -1
  16. package/src/bridge.ts +12 -4
  17. package/src/cli/account-api.ts +4 -2
  18. package/src/cli/account-extended.ts +34 -0
  19. package/src/cli/account.ts +3 -1
  20. package/src/cli/claude.ts +6 -1
  21. package/src/cli/help.ts +25 -4
  22. package/src/cli/init.ts +38 -3
  23. package/src/cli/models.ts +206 -7
  24. package/src/codex/auth-api.ts +27 -3
  25. package/src/codex/catalog.ts +72 -2
  26. package/src/config.ts +60 -3
  27. package/src/oauth/github-copilot.ts +1 -0
  28. package/src/oauth/index.ts +5 -4
  29. package/src/oauth/kiro.ts +12 -1
  30. package/src/oauth/store.ts +11 -0
  31. package/src/oauth/types.ts +3 -1
  32. package/src/providers/antigravity-models.ts +103 -5
  33. package/src/providers/api-keys.ts +12 -0
  34. package/src/providers/openrouter-routing.ts +102 -0
  35. package/src/providers/registry.ts +7 -5
  36. package/src/router.ts +2 -1
  37. package/src/server/auth-cors.ts +5 -0
  38. package/src/server/management-api.ts +143 -6
  39. package/src/server/responses.ts +16 -4
  40. package/src/types.ts +42 -1
  41. package/src/update/index.ts +19 -2
  42. package/src/update/job.ts +13 -3
  43. package/src/usage/expected-prices.ts +10 -0
  44. package/src/usage/summary.ts +43 -0
  45. package/gui/dist/assets/index-BPa0R6EN.js +0 -46
  46. package/gui/dist/assets/index-BY7KvJRB.css +0 -1
@@ -1,9 +1,12 @@
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 separate CCA wire ids, visible client aliases, and
5
- // hidden compatibility aliases for saved selections. 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
11
  "gemini-3.6-flash-low",
9
12
  "gemini-3.6-flash-medium",
@@ -15,12 +18,50 @@ const ANTIGRAVITY_WIRE_MODELS = [
15
18
  "gpt-oss-120b-medium",
16
19
  ];
17
20
 
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) ──
18
51
  const ANTIGRAVITY_VISIBLE_MODEL_ALIASES: Record<string, string> = {
19
52
  "gemini-3.1-pro-high": "gemini-pro-agent",
20
53
  "gemini-3.1-pro-preview": "gemini-pro-agent",
21
54
  };
22
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.
23
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",
24
65
  "gemini-3.5-flash-extra-low": "gemini-3.6-flash-low",
25
66
  "gemini-3.5-flash-low": "gemini-3.6-flash-medium",
26
67
  "gemini-3.5-flash-mid": "gemini-3.6-flash-medium",
@@ -33,9 +74,13 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
33
74
  ...ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES,
34
75
  };
35
76
 
77
+ // Picker-visible: collapsed base models only.
36
78
  export const ANTIGRAVITY_MODELS = [
37
- ...ANTIGRAVITY_WIRE_MODELS,
38
- ...Object.keys(ANTIGRAVITY_VISIBLE_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",
39
84
  ];
40
85
 
41
86
  // Context windows from the upstream `:fetchAvailableModels` maxTokens per model.
@@ -51,6 +96,10 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
51
96
  };
52
97
 
53
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.
54
103
  ...ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS,
55
104
  ...Object.fromEntries(
56
105
  Object.entries(ANTIGRAVITY_MODEL_ALIASES).map(([alias, wire]) => [
@@ -63,3 +112,52 @@ export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
63
112
  export function resolveAntigravityWireModelId(modelId: string): string {
64
113
  return ANTIGRAVITY_MODEL_ALIASES[modelId] ?? modelId;
65
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[];
@@ -638,9 +640,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
638
640
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
639
641
  {
640
642
  id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true,
641
- dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.1-pro-preview"],
642
- modelContextWindows: { "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000 },
643
- modelInputModalities: { "gemini-3.6-flash": ["text", "image"] },
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"] },
644
646
  modelReasoningEfforts: {
645
647
  "gemini-3.6-flash": ["minimal", "low", "medium", "high"],
646
648
  "gemini-3.5-flash": ["minimal", "low", "medium", "high"],
@@ -651,7 +653,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
651
653
  // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
652
654
  // evidence from ai.google.dev does not establish Vertex publisher availability.
653
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"] },
654
- { 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-medium", 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"] },
655
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" },
656
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" },
657
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" },
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",
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
2
3
  import type { CatalogModel } from "../codex/catalog";
3
4
  import { invalidateCodexModelsCache, nativeModelRows } from "../codex/catalog";
@@ -46,7 +47,7 @@ import {
46
47
  setDebugSettings,
47
48
  type DebugFlag,
48
49
  } from "../lib/debug-settings";
49
- import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
50
+ import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types";
50
51
  import { drainAndShutdown } from "./lifecycle";
51
52
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
52
53
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
@@ -798,9 +799,26 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
798
799
  native: true,
799
800
  ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
800
801
  }));
801
- return jsonResponse([...native, ...models.map(m => {
802
+ const customModels = (config.customModels ?? []).map(cm => {
803
+ const namespaced = routedSlug(cm.provider, cm.modelId);
804
+ return {
805
+ provider: cm.provider,
806
+ id: cm.modelId,
807
+ namespaced,
808
+ disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
809
+ custom: true,
810
+ customId: cm.id,
811
+ displayName: cm.displayName,
812
+ ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
813
+ ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
814
+ };
815
+ });
816
+ // Custom metadata wins when a live/static routed row resolves to the same Codex-facing slug.
817
+ const customNamespaced = new Set(customModels.map(c => c.namespaced));
818
+ const dedupedRouted = models.map(m => {
802
819
  // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
803
820
  const namespaced = routedSlug(m.provider, m.id);
821
+ if (customNamespaced.has(namespaced)) return null;
804
822
  const contextCap = providerContextCap(config, m.provider);
805
823
  return {
806
824
  ...m,
@@ -808,7 +826,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
808
826
  disabled: [...disabled].some(stored => slugEquals(stored, m.provider, m.id)),
809
827
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
810
828
  };
811
- })]);
829
+ }).filter(Boolean);
830
+ return jsonResponse([...native, ...dedupedRouted, ...customModels]);
812
831
  }
813
832
 
814
833
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
@@ -880,6 +899,96 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
880
899
  return jsonResponse({ ok: true, disabled });
881
900
  }
882
901
 
902
+ if (url.pathname === "/api/custom-models" && req.method === "GET") {
903
+ return jsonResponse(config.customModels ?? []);
904
+ }
905
+
906
+ if (url.pathname === "/api/custom-models" && req.method === "POST") {
907
+ let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
908
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
909
+ const provider = typeof body.provider === "string" ? body.provider.trim() : "";
910
+ const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
911
+ if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
912
+ if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
913
+ if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
914
+ if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
915
+ const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
916
+ if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
917
+ const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
918
+ const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
919
+ const existing = config.customModels ?? [];
920
+ const newSlug = routedSlug(provider, modelId);
921
+ if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
922
+ return jsonResponse({ error: "duplicate model" }, 409);
923
+ }
924
+ const entry: OcxCustomModel = {
925
+ id: randomUUID(),
926
+ provider,
927
+ modelId,
928
+ ...(displayName ? { displayName } : {}),
929
+ ...(contextWindow ? { contextWindow } : {}),
930
+ ...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
931
+ addedAt: new Date().toISOString(),
932
+ };
933
+ config.customModels = [...existing, entry];
934
+ const { saveConfig: save } = await import("../config");
935
+ save(config);
936
+ await refreshCodexCatalogBestEffort();
937
+ return jsonResponse(entry, 201);
938
+ }
939
+
940
+ const customPutMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
941
+ if (customPutMatch && req.method === "PUT") {
942
+ let id: string;
943
+ try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
944
+ let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
945
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
946
+ const list = config.customModels ?? [];
947
+ const idx = list.findIndex(cm => cm.id === id);
948
+ if (idx === -1) return jsonResponse({ error: "not found" }, 404);
949
+ const cm = { ...list[idx] };
950
+ if (typeof body.modelId === "string" && body.modelId.trim()) {
951
+ if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
952
+ cm.modelId = body.modelId.trim();
953
+ }
954
+ if (body.displayName !== undefined) {
955
+ const dn = typeof body.displayName === "string" ? body.displayName.trim() : "";
956
+ if (dn.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
957
+ cm.displayName = dn || undefined;
958
+ }
959
+ if (body.contextWindow !== undefined) {
960
+ cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
961
+ }
962
+ if (body.inputModalities !== undefined) {
963
+ cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
964
+ }
965
+ const updatedSlug = routedSlug(cm.provider, cm.modelId);
966
+ if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
967
+ return jsonResponse({ error: "duplicate model" }, 409);
968
+ }
969
+ list[idx] = cm;
970
+ config.customModels = list;
971
+ const { saveConfig: save } = await import("../config");
972
+ save(config);
973
+ await refreshCodexCatalogBestEffort();
974
+ return jsonResponse(cm);
975
+ }
976
+
977
+ const customDelMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
978
+ if (customDelMatch && req.method === "DELETE") {
979
+ let id: string;
980
+ try { id = decodeURIComponent(customDelMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
981
+ const list = config.customModels ?? [];
982
+ const idx = list.findIndex(cm => cm.id === id);
983
+ if (idx === -1) return jsonResponse({ error: "not found" }, 404);
984
+ list.splice(idx, 1);
985
+ config.customModels = list.length > 0 ? list : undefined;
986
+ const { saveConfig: save } = await import("../config");
987
+ save(config);
988
+ await refreshCodexCatalogBestEffort();
989
+ return jsonResponse({ ok: true });
990
+ }
991
+
883
992
  // multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
884
993
  // boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
885
994
  // the catalog so multi-agent surface metadata stays fresh. The catalog build
@@ -1375,18 +1484,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1375
1484
  }
1376
1485
  }
1377
1486
  // addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
1378
- const { url: authUrl, instructions } = await startLoginFlow(provider, {
1487
+ const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
1379
1488
  forceLogin: body.addAccount === true || reauth,
1380
1489
  ...(accountId ? { reauthAccountId: accountId } : {}),
1381
1490
  });
1382
1491
  upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
1383
- if (authUrl) {
1492
+ if (authUrl && !deviceCode) {
1384
1493
  // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
1385
1494
  // window.open is popup-blocked because it runs after an await, not a direct click.
1386
1495
  const { openUrl } = await import("../lib/open-url");
1387
1496
  openUrl(authUrl);
1388
1497
  }
1389
- return jsonResponse({ url: authUrl, instructions });
1498
+ return jsonResponse({ url: authUrl, instructions, deviceCode });
1390
1499
  } catch (err) {
1391
1500
  return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
1392
1501
  }
@@ -1454,6 +1563,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1454
1563
  clearProviderQuotaCache();
1455
1564
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
1456
1565
  }
1566
+ if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
1567
+ const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
1568
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
1569
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1570
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
1571
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1572
+ if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
1573
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
1574
+ return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
1575
+ }
1576
+ const { setAccountAlias } = await import("../oauth/store");
1577
+ if (!(await setAccountAlias(provider, accountId, alias || undefined))) return jsonResponse({ error: "account not found" }, 404);
1578
+ return jsonResponse({ ok: true, provider, accountId, alias: alias || null });
1579
+ }
1457
1580
  if (url.pathname === "/api/oauth/accounts" && req.method === "DELETE") {
1458
1581
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1459
1582
  const id = url.searchParams.get("id") ?? "";
@@ -1507,6 +1630,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1507
1630
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
1508
1631
  return jsonResponse({ ok: true, name, activeId: body.id });
1509
1632
  }
1633
+ if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
1634
+ const body = await req.json().catch(() => ({})) as { name?: unknown; id?: unknown; alias?: unknown };
1635
+ const name = typeof body.name === "string" ? body.name.trim() : "";
1636
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1637
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
1638
+ if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
1639
+ if (!id) return jsonResponse({ error: "missing id" }, 400);
1640
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
1641
+ return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
1642
+ }
1643
+ const { setProviderApiKeyLabel } = await import("../providers/api-keys");
1644
+ if (!setProviderApiKeyLabel(config, name, id, alias || undefined)) return jsonResponse({ error: "key not found" }, 404);
1645
+ return jsonResponse({ ok: true, name, id, alias: alias || null });
1646
+ }
1510
1647
  if (url.pathname === "/api/providers/keys" && req.method === "DELETE") {
1511
1648
  const name = (url.searchParams.get("name") ?? "").trim();
1512
1649
  const id = url.searchParams.get("id") ?? "";
@@ -6,7 +6,7 @@ import {
6
6
  } from "../config";
7
7
  import { parseRequest } from "../responses/parser";
8
8
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../responses/compaction";
9
- import { FORWARD_HEADERS } from "../adapters/openai-responses";
9
+ import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../adapters/openai-responses";
10
10
  import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
11
11
  import { routeModel } from "../router";
12
12
  import {
@@ -590,6 +590,15 @@ function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
590
590
  };
591
591
  }
592
592
 
593
+ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
594
+ const childHeaders = new Headers(parentHeaders);
595
+ // Combo children re-serialize already-decoded JSON. Keeping transport metadata from
596
+ // the parent would make the child decoder treat plain JSON as compressed bytes.
597
+ childHeaders.delete("content-length");
598
+ childHeaders.delete("content-encoding");
599
+ return childHeaders;
600
+ }
601
+
593
602
  async function handleComboResponses(
594
603
  req: Request,
595
604
  rawBody: unknown,
@@ -630,8 +639,7 @@ async function handleComboResponses(
630
639
  comboDefaultEffort(config, comboId),
631
640
  supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
632
641
  );
633
- const childHeaders = new Headers(req.headers);
634
- childHeaders.delete("content-length");
642
+ const childHeaders = buildComboChildHeaders(req.headers);
635
643
  const childRequest = new Request(req.url, {
636
644
  method: req.method,
637
645
  headers: childHeaders,
@@ -1787,7 +1795,11 @@ export async function handleResponsesCompact(
1787
1795
  }
1788
1796
  const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
1789
1797
  if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
1790
- const { reasoning: _reasoning, ...compactBody } = raw as typeof raw & { reasoning?: unknown };
1798
+ const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown };
1799
+ // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's
1800
+ // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
1801
+ // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
1802
+ const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
1791
1803
  const compactUrl = `${base}/responses/compact`;
1792
1804
  const compactThreadId = req.headers.get("x-codex-parent-thread-id");
1793
1805
  const connectMs = config.connectTimeoutMs ?? 200_000;