@yansigit/opencodex 2.33.1 → 2.35.0
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 +3 -3
- package/gui/dist/assets/index-BjCaHxdz.js +112 -0
- package/gui/dist/assets/index-DLkXOXLC.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/adapters/anthropic.ts +79 -2
- package/src/adapters/command-code.ts +40 -3
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/discovery.ts +60 -2
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +9 -9
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-shell.ts +103 -6
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +74 -39
- package/src/adapters/cursor/request-builder.ts +32 -10
- package/src/adapters/cursor/tool-definitions.ts +13 -10
- package/src/adapters/cursor/tool-result-normalize.ts +35 -3
- package/src/adapters/cursor/types.ts +16 -4
- package/src/adapters/cursor.ts +97 -70
- package/src/adapters/google-antigravity-replay.ts +105 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +41 -12
- package/src/adapters/google-http.ts +12 -11
- package/src/adapters/google.ts +156 -65
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +15 -0
- package/src/adapters/kiro-tools.ts +43 -15
- package/src/adapters/kiro.ts +54 -9
- package/src/adapters/openai-chat.ts +286 -242
- package/src/adapters/openai-responses.ts +334 -24
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +2 -2
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/bridge.ts +67 -26
- package/src/chat/inbound.ts +14 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/outbound.ts +10 -8
- package/src/cli/account-api.ts +27 -7
- package/src/cli/account-extended.ts +10 -3
- package/src/cli/account.ts +29 -5
- package/src/cli/alias.ts +66 -0
- package/src/cli/claude.ts +26 -1
- package/src/cli/dispatch.ts +13 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/init.ts +1 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +13 -7
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/registry.ts +6 -1
- package/src/cli/telemetry-commands.ts +25 -0
- package/src/cli/v2.ts +34 -10
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +80 -6
- package/src/codex/auth-api.ts +48 -8
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/catalog/aggregation.ts +6 -0
- package/src/codex/catalog/model-metadata.ts +13 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +16 -0
- package/src/codex/catalog/provider-fetch.ts +20 -37
- package/src/codex/catalog/sync.ts +127 -2
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +22 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +18 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +14 -1
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +123 -18
- package/src/codex/routing.ts +9 -0
- package/src/codex/subagent-model-fallback.ts +198 -27
- package/src/codex/transition-state.ts +107 -8
- package/src/combos/types.ts +10 -0
- package/src/compatibility/openai-responses.ts +33 -1
- package/src/config/autonomous-remediation.ts +21 -0
- package/src/config/provider-validation.ts +14 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +191 -17
- package/src/generated/compatibility-version.json +229 -153
- package/src/images/loop.ts +5 -4
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +11 -2
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/oauth/aistudio-credentials.ts +65 -0
- package/src/oauth/aistudio-native-daemon.ts +54 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/index.ts +57 -5
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/login-cli.ts +19 -20
- package/src/oauth/types.ts +15 -0
- package/src/providers/codex-capacity.ts +5 -2
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +28 -0
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +73 -14
- package/src/providers/registry.ts +160 -68
- package/src/providers/slug-codec.ts +52 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +34 -10
- package/src/responses/parser.ts +4 -0
- package/src/responses/schema.ts +5 -1
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/router.ts +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +23 -0
- package/src/server/chat-completions.ts +24 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/effort-policy.ts +16 -0
- package/src/server/index.ts +152 -80
- package/src/server/lifecycle.ts +52 -1
- package/src/server/management/agent-settings-routes.ts +31 -15
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +27 -9
- package/src/server/management/context.ts +9 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +266 -0
- package/src/server/management/provider-routes.ts +137 -3
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management-api.ts +2 -0
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +21 -0
- package/src/server/request-log.ts +46 -20
- package/src/server/responses/collaboration.ts +42 -5
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/core.ts +571 -137
- package/src/server/responses/empty-completion-guard.ts +35 -0
- package/src/server/responses/fetch-helpers.ts +14 -6
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +35 -5
- package/src/server/responses-custom-tool-repair.ts +52 -7
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +15 -4
- package/src/telemetry/dispatcher.ts +44 -0
- package/src/telemetry/fingerprint.ts +24 -0
- package/src/telemetry/hook.ts +43 -0
- package/src/telemetry/ledger.ts +54 -0
- package/src/telemetry/types.ts +23 -0
- package/src/types/config.ts +66 -14
- package/src/types/provider.ts +77 -0
- package/src/types/request.ts +16 -10
- package/src/types/tools.ts +20 -25
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +17 -7
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-CIDo4y4k.js +0 -102
- package/gui/dist/assets/index-DrSQdTRd.css +0 -1
- package/src/server/aistudio-ws-hub.ts +0 -295
|
@@ -5,6 +5,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nati
|
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_SUBAGENT_MODELS,
|
|
7
7
|
codexAutoStartEnabled,
|
|
8
|
+
deleteConfigTopLevelKey,
|
|
8
9
|
hasOwnProvider,
|
|
9
10
|
isValidProviderName,
|
|
10
11
|
multiAgentGuidanceEnabled,
|
|
@@ -298,6 +299,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
298
299
|
streamMode: config.streamMode ?? "auto",
|
|
299
300
|
appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
|
|
300
301
|
codexAccountPickerEnabled: codexAccountPickerEnabled(config),
|
|
302
|
+
// Absent means hidden, so the GUI renders the switch without having to know that
|
|
303
|
+
// `undefined` and `false` mean the same thing.
|
|
304
|
+
showCodexSparkQuota: config.showCodexSparkQuota === true,
|
|
301
305
|
// Absent means the historical auto-open, so the GUI can render the toggle
|
|
302
306
|
// without having to know that `undefined` and `true` mean the same thing.
|
|
303
307
|
oauthOpenBrowser: config.oauthOpenBrowser !== false,
|
|
@@ -386,13 +390,15 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
386
390
|
appOwnedMemoryBudgetMb?: unknown;
|
|
387
391
|
codexAccountPickerEnabled?: unknown;
|
|
388
392
|
oauthOpenBrowser?: unknown;
|
|
393
|
+
showCodexSparkQuota?: unknown;
|
|
389
394
|
};
|
|
390
395
|
if (body.codexAutoStart === undefined
|
|
391
396
|
&& body.streamMode === undefined
|
|
392
397
|
&& body.appOwnedMemoryBudgetMb === undefined
|
|
393
398
|
&& body.codexAccountPickerEnabled === undefined
|
|
394
|
-
&& body.oauthOpenBrowser === undefined
|
|
395
|
-
|
|
399
|
+
&& body.oauthOpenBrowser === undefined
|
|
400
|
+
&& body.showCodexSparkQuota === undefined) {
|
|
401
|
+
return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, or showCodexSparkQuota" }, 400);
|
|
396
402
|
}
|
|
397
403
|
if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
|
|
398
404
|
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
@@ -407,6 +413,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
407
413
|
&& typeof body.codexAccountPickerEnabled !== "boolean") {
|
|
408
414
|
return jsonResponse({ error: "codexAccountPickerEnabled boolean is required" }, 400);
|
|
409
415
|
}
|
|
416
|
+
if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") {
|
|
417
|
+
return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400);
|
|
418
|
+
}
|
|
410
419
|
if (body.appOwnedMemoryBudgetMb !== undefined && (
|
|
411
420
|
typeof body.appOwnedMemoryBudgetMb !== "number"
|
|
412
421
|
|| !Number.isInteger(body.appOwnedMemoryBudgetMb)
|
|
@@ -428,6 +437,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
428
437
|
hasCodexAccountPickerEnabled: Object.hasOwn(config, "codexAccountPickerEnabled"),
|
|
429
438
|
oauthOpenBrowser: config.oauthOpenBrowser,
|
|
430
439
|
hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
|
|
440
|
+
showCodexSparkQuota: config.showCodexSparkQuota,
|
|
441
|
+
hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"),
|
|
431
442
|
};
|
|
432
443
|
const pickerWasEnabled = codexAccountPickerEnabled(config);
|
|
433
444
|
let pickerIsEnabled = pickerWasEnabled;
|
|
@@ -437,7 +448,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
437
448
|
}
|
|
438
449
|
if (body.streamMode !== undefined) {
|
|
439
450
|
if (body.streamMode === "auto") {
|
|
440
|
-
|
|
451
|
+
deleteConfigTopLevelKey(config, "streamMode");
|
|
441
452
|
} else {
|
|
442
453
|
config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
|
|
443
454
|
}
|
|
@@ -454,25 +465,31 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
454
465
|
if (typeof body.oauthOpenBrowser === "boolean") {
|
|
455
466
|
config.oauthOpenBrowser = body.oauthOpenBrowser;
|
|
456
467
|
}
|
|
468
|
+
if (typeof body.showCodexSparkQuota === "boolean") {
|
|
469
|
+
config.showCodexSparkQuota = body.showCodexSparkQuota;
|
|
470
|
+
}
|
|
457
471
|
pickerIsEnabled = codexAccountPickerEnabled(config);
|
|
458
472
|
(deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
|
|
459
473
|
} catch (error) {
|
|
460
474
|
if (previousSettings.hasCodexAutoStart) config.codexAutoStart = previousSettings.codexAutoStart;
|
|
461
|
-
else
|
|
475
|
+
else deleteConfigTopLevelKey(config, "codexAutoStart");
|
|
462
476
|
if (previousSettings.hasStreamMode) config.streamMode = previousSettings.streamMode;
|
|
463
|
-
else
|
|
477
|
+
else deleteConfigTopLevelKey(config, "streamMode");
|
|
464
478
|
if (previousSettings.hasAppOwnedMemoryBudgetMb) {
|
|
465
479
|
config.appOwnedMemoryBudgetMb = previousSettings.appOwnedMemoryBudgetMb;
|
|
466
|
-
} else
|
|
480
|
+
} else deleteConfigTopLevelKey(config, "appOwnedMemoryBudgetMb");
|
|
467
481
|
if (previousSettings.hasCodexAccountNamespaces) {
|
|
468
482
|
config.codexAccountNamespaces = previousSettings.codexAccountNamespaces;
|
|
469
|
-
} else
|
|
483
|
+
} else deleteConfigTopLevelKey(config, "codexAccountNamespaces");
|
|
470
484
|
if (previousSettings.hasCodexAccountPickerEnabled) {
|
|
471
485
|
config.codexAccountPickerEnabled = previousSettings.codexAccountPickerEnabled;
|
|
472
|
-
} else
|
|
486
|
+
} else deleteConfigTopLevelKey(config, "codexAccountPickerEnabled");
|
|
473
487
|
if (previousSettings.hasOauthOpenBrowser) {
|
|
474
488
|
config.oauthOpenBrowser = previousSettings.oauthOpenBrowser;
|
|
475
|
-
} else
|
|
489
|
+
} else deleteConfigTopLevelKey(config, "oauthOpenBrowser");
|
|
490
|
+
if (previousSettings.hasShowCodexSparkQuota) {
|
|
491
|
+
config.showCodexSparkQuota = previousSettings.showCodexSparkQuota;
|
|
492
|
+
} else deleteConfigTopLevelKey(config, "showCodexSparkQuota");
|
|
476
493
|
throw error;
|
|
477
494
|
}
|
|
478
495
|
if (typeof body.appOwnedMemoryBudgetMb === "number") {
|
|
@@ -494,6 +511,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
494
511
|
codexAccountPickerEnabled: pickerIsEnabled,
|
|
495
512
|
oauthOpenBrowser: config.oauthOpenBrowser !== false,
|
|
496
513
|
catalogRefreshPending,
|
|
514
|
+
showCodexSparkQuota: config.showCodexSparkQuota === true,
|
|
497
515
|
startupHealth: await readStartupHealth(config),
|
|
498
516
|
});
|
|
499
517
|
}
|
|
@@ -6,6 +6,7 @@ import type { StartupHealth } from "../../codex/autostart-health";
|
|
|
6
6
|
import type { StartupInstallAction } from "../startup-action-control";
|
|
7
7
|
import type { ManagementPrincipal } from "../management-auth";
|
|
8
8
|
import type { CatalogModel } from "../../codex/catalog";
|
|
9
|
+
import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
|
|
9
10
|
import type { injectGrokConfig } from "../../grok/inject";
|
|
10
11
|
import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
|
|
11
12
|
import type { RuntimePortState } from "../../config/process-state";
|
|
@@ -91,6 +92,14 @@ export interface ManagementApiDeps {
|
|
|
91
92
|
* state inside their temporary Codex home.
|
|
92
93
|
*/
|
|
93
94
|
codexLogGuardMaintenanceDeps?: CodexLogGuardMaintenanceDeps;
|
|
95
|
+
/**
|
|
96
|
+
* Prompt-layer path seam. Production leaves this unset and
|
|
97
|
+
* `src/codex/prompt-layers.ts` resolves the real CODEX_HOME. A route test
|
|
98
|
+
* that could not inject paths would read AND WRITE the developer's live
|
|
99
|
+
* ~/.codex/config.toml — the same class of incident
|
|
100
|
+
* `saveConfigPreservingClaudeCode` above exists to prevent.
|
|
101
|
+
*/
|
|
102
|
+
codexPromptPaths?: CodexPromptPaths;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
|
|
@@ -209,7 +209,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
209
209
|
const fallbackAccounts: Record<string, string> = {};
|
|
210
210
|
for (const p of listOAuthProviders()) {
|
|
211
211
|
const set = getAccountSet(p);
|
|
212
|
-
if (set
|
|
212
|
+
if (set && set.accounts.length === 1 && set.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
|
|
213
213
|
}
|
|
214
214
|
const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
|
|
215
215
|
projectUsageSummary(summary, filter, entries, fallbackAccounts);
|
|
@@ -99,6 +99,8 @@ import { deriveProviderPresets } from "../../providers/derive";
|
|
|
99
99
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
100
100
|
import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
101
101
|
import { knownModelIdsForProvider } from "../../router";
|
|
102
|
+
import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases";
|
|
103
|
+
import { comboPublicModelId } from "../../combos/types";
|
|
102
104
|
import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos";
|
|
103
105
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
104
106
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
@@ -149,6 +151,12 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
|
|
|
149
151
|
import type { ManagementContext } from "./context";
|
|
150
152
|
import { listManagementModelRows, loadExportModels } from "./model-rows";
|
|
151
153
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
154
|
+
import {
|
|
155
|
+
hasModelPreset,
|
|
156
|
+
markModelPresetDiverged,
|
|
157
|
+
materializeModelPreset,
|
|
158
|
+
modelPresetFor,
|
|
159
|
+
} from "../../providers/model-presets";
|
|
152
160
|
|
|
153
161
|
/**
|
|
154
162
|
* Counts read back off the SERIALIZED document rather than recomputed from the input rows.
|
|
@@ -171,6 +179,158 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
171
179
|
// ~/.opencodex/config.json with the `existing-uuid` test fixture.
|
|
172
180
|
const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
173
181
|
|
|
182
|
+
if (url.pathname === "/api/model-discovery" && req.method === "GET") {
|
|
183
|
+
const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
|
|
184
|
+
name, provider.newModelPolicy ?? "inherit",
|
|
185
|
+
]));
|
|
186
|
+
const recentArrivals = Object.fromEntries(Object.entries(config.modelDiscovery?.recentArrivals ?? {}).map(([name, rows]) => [
|
|
187
|
+
name,
|
|
188
|
+
rows.map(row => ({
|
|
189
|
+
...row,
|
|
190
|
+
state: (config.disabledModels ?? []).some(slug => slugEquals(slug, name, row.id))
|
|
191
|
+
? "auto-disabled" : "enabled",
|
|
192
|
+
})),
|
|
193
|
+
]));
|
|
194
|
+
const baselineCounts = Object.fromEntries(Object.entries(config.modelDiscovery?.knownModels ?? {}).map(([name, baseline]) => [
|
|
195
|
+
name, baseline.ids.length,
|
|
196
|
+
]));
|
|
197
|
+
return jsonResponse({
|
|
198
|
+
policy: config.modelDiscovery?.newModelPolicy ?? "on", providers, recentArrivals, baselineCounts,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (url.pathname === "/api/model-discovery" && req.method === "PUT") {
|
|
203
|
+
let body: { policy?: unknown; provider?: unknown };
|
|
204
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
205
|
+
if (body.policy !== "on" && body.policy !== "off") return jsonResponse({ error: "policy must be on or off" }, 400);
|
|
206
|
+
const provider = typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : null;
|
|
207
|
+
let baselineBootstrapped = false;
|
|
208
|
+
if (provider) {
|
|
209
|
+
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
210
|
+
config.providers[provider].newModelPolicy = body.policy;
|
|
211
|
+
} else {
|
|
212
|
+
const wasAbsent = config.modelDiscovery?.newModelPolicy === undefined;
|
|
213
|
+
config.modelDiscovery ??= {};
|
|
214
|
+
config.modelDiscovery.newModelPolicy = body.policy;
|
|
215
|
+
if (body.policy === "off" && wasAbsent) {
|
|
216
|
+
const models = await fetchAllModels(config);
|
|
217
|
+
const known = config.modelDiscovery.knownModels ??= {};
|
|
218
|
+
const at = new Date().toISOString();
|
|
219
|
+
for (const name of Object.keys(config.providers)) {
|
|
220
|
+
known[name] ??= { ids: [...new Set(models.filter(m => m.provider === name).map(m => m.id))].sort(), removed: [], updatedAt: at };
|
|
221
|
+
}
|
|
222
|
+
baselineBootstrapped = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
persistConfig(config);
|
|
226
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
227
|
+
return jsonResponse({ ok: true, policy: body.policy, provider, ...(baselineBootstrapped ? { baselineBootstrapped } : {}), catalogRefresh });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (url.pathname === "/api/model-discovery/acknowledge" && req.method === "POST") {
|
|
231
|
+
let body: { provider?: unknown; ids?: unknown };
|
|
232
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
233
|
+
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
234
|
+
if (!provider || !Array.isArray(body.ids) || body.ids.some(id => typeof id !== "string")) {
|
|
235
|
+
return jsonResponse({ error: "provider and string ids are required" }, 400);
|
|
236
|
+
}
|
|
237
|
+
const acknowledged = new Set(body.ids as string[]);
|
|
238
|
+
const recent = config.modelDiscovery?.recentArrivals;
|
|
239
|
+
if (recent?.[provider]) recent[provider] = recent[provider].filter(row => !acknowledged.has(row.id));
|
|
240
|
+
persistConfig(config);
|
|
241
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
242
|
+
return jsonResponse({ ok: true, provider, acknowledged: [...acknowledged], catalogRefresh });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (url.pathname === "/api/aliases" && req.method === "GET") {
|
|
246
|
+
const providers: Record<string, string> = {};
|
|
247
|
+
const models: Record<string, Record<string, { alias: string; source: "user" | "builtin"; stale?: boolean }>> = {};
|
|
248
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
249
|
+
if (provider.alias) providers[name] = provider.alias;
|
|
250
|
+
const known = knownModelIdsForProvider(name, provider, config);
|
|
251
|
+
const knownSet = new Set(known);
|
|
252
|
+
const rows: Record<string, { alias: string; source: "user" | "builtin"; stale?: boolean }> = {};
|
|
253
|
+
for (const [id, value] of effectiveModelAliases(config, provider, new Set([...known, ...Object.keys(provider.modelAliases ?? {})]))) {
|
|
254
|
+
rows[id] = { ...value, ...(!knownSet.has(id) ? { stale: true } : {}) };
|
|
255
|
+
}
|
|
256
|
+
if (Object.keys(rows).length) models[name] = rows;
|
|
257
|
+
}
|
|
258
|
+
return jsonResponse({ providers, models, defaults: {
|
|
259
|
+
global: config.defaultModelAliases ?? false,
|
|
260
|
+
providers: Object.fromEntries(Object.entries(config.providers).filter(([, p]) => p.defaultAliases !== undefined).map(([n, p]) => [n, p.defaultAliases])),
|
|
261
|
+
} });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const providerAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/alias$/);
|
|
265
|
+
if (providerAliasMatch && req.method === "PUT") {
|
|
266
|
+
const name = decodeURIComponent(providerAliasMatch[1]!);
|
|
267
|
+
// `keys` is not a provider name: `/api/providers/keys/alias` is the API-KEY POOL's rename
|
|
268
|
+
// endpoint (oauth-account-routes.ts), and model routes are dispatched BEFORE it. Without
|
|
269
|
+
// this guard the alias route matched `name = "keys"`, found no such provider, and returned
|
|
270
|
+
// 404 for every key-pool rename.
|
|
271
|
+
if (name === "keys") return null;
|
|
272
|
+
const provider = config.providers[name];
|
|
273
|
+
if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
|
|
274
|
+
let raw: unknown;
|
|
275
|
+
try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
276
|
+
if (!isPlainRecord(raw) || (raw.alias !== null && typeof raw.alias !== "string")) return jsonResponse({ error: "alias must be a string or null" }, 400, req, config);
|
|
277
|
+
const alias = typeof raw.alias === "string" ? raw.alias.trim() : null;
|
|
278
|
+
if (alias && !isValidProviderName(alias)) return jsonResponse({ error: "invalid provider alias" }, 400, req, config);
|
|
279
|
+
const lower = alias?.toLowerCase();
|
|
280
|
+
const collision = lower && Object.entries(config.providers).find(([other, p]) =>
|
|
281
|
+
other !== name && (other.toLowerCase() === lower || p.alias?.toLowerCase() === lower));
|
|
282
|
+
const comboCollision = lower && Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower);
|
|
283
|
+
const accountCollision = lower && Object.keys(config.codexAccountNamespaces ?? {}).find(value => value.toLowerCase() === lower);
|
|
284
|
+
if (collision || comboCollision || accountCollision) return jsonResponse({ error: `alias conflicts with '${collision?.[0] ?? comboCollision?.[0] ?? accountCollision}'` }, 409, req, config);
|
|
285
|
+
if (alias) provider.alias = alias; else delete provider.alias;
|
|
286
|
+
persistConfig(config);
|
|
287
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
288
|
+
return jsonResponse({ ok: true, provider: name, alias, catalogRefresh });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const modelAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-aliases$/);
|
|
292
|
+
if (modelAliasMatch && req.method === "PUT") {
|
|
293
|
+
const name = decodeURIComponent(modelAliasMatch[1]!);
|
|
294
|
+
if (name === "keys") return null;
|
|
295
|
+
const provider = config.providers[name];
|
|
296
|
+
if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
|
|
297
|
+
let raw: unknown;
|
|
298
|
+
try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
299
|
+
if (!isPlainRecord(raw) || (raw.set !== undefined && !isPlainRecord(raw.set)) || (raw.remove !== undefined && !Array.isArray(raw.remove))) return jsonResponse({ error: "invalid model alias update" }, 400, req, config);
|
|
300
|
+
const next = { ...(provider.modelAliases ?? {}) };
|
|
301
|
+
for (const id of (raw.remove ?? []) as unknown[]) if (typeof id === "string") delete next[id];
|
|
302
|
+
const conflicts: Array<{ alias: string; heldBy: string }> = [];
|
|
303
|
+
const known = knownModelIdsForProvider(name, provider, config);
|
|
304
|
+
for (const [id, value] of Object.entries((raw.set ?? {}) as Record<string, unknown>)) {
|
|
305
|
+
if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value)) return jsonResponse({ error: `invalid model alias for '${id}'` }, 400, req, config);
|
|
306
|
+
const lower = value.toLowerCase();
|
|
307
|
+
const heldBy = Object.entries(next).find(([other, alias]) => other !== id && alias.toLowerCase() === lower)?.[0]
|
|
308
|
+
?? known.find(native => native.toLowerCase() === lower)
|
|
309
|
+
?? Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower)?.[0];
|
|
310
|
+
if (heldBy || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) conflicts.push({ alias: value, heldBy: heldBy ?? "native OpenAI family" });
|
|
311
|
+
else next[id] = value;
|
|
312
|
+
}
|
|
313
|
+
if (conflicts.length) return jsonResponse({ error: "model alias collision", conflicts }, 409, req, config);
|
|
314
|
+
provider.modelAliases = next;
|
|
315
|
+
persistConfig(config);
|
|
316
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
317
|
+
return jsonResponse({ ok: true, aliases: next, catalogRefresh });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (url.pathname === "/api/default-aliases" && req.method === "PUT") {
|
|
321
|
+
let raw: unknown;
|
|
322
|
+
try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
323
|
+
if (!isPlainRecord(raw) || typeof raw.enabled !== "boolean" || (raw.provider !== undefined && typeof raw.provider !== "string")) return jsonResponse({ error: "enabled must be boolean" }, 400, req, config);
|
|
324
|
+
if (typeof raw.provider === "string") {
|
|
325
|
+
const provider = config.providers[raw.provider];
|
|
326
|
+
if (!provider) return jsonResponse({ error: `provider '${raw.provider}' not found` }, 404, req, config);
|
|
327
|
+
provider.defaultAliases = raw.enabled;
|
|
328
|
+
} else config.defaultModelAliases = raw.enabled;
|
|
329
|
+
persistConfig(config);
|
|
330
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
331
|
+
return jsonResponse({ ok: true, catalogRefresh });
|
|
332
|
+
}
|
|
333
|
+
|
|
174
334
|
if (url.pathname === "/api/catalog" && req.method === "GET") {
|
|
175
335
|
const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
|
|
176
336
|
const catalog = readCatalog(readCodexCatalogPath());
|
|
@@ -370,6 +530,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
370
530
|
providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])];
|
|
371
531
|
}
|
|
372
532
|
disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target)));
|
|
533
|
+
const arrivals = config.modelDiscovery?.recentArrivals?.[provider];
|
|
534
|
+
if (arrivals) config.modelDiscovery!.recentArrivals![provider] = arrivals.filter(row => (
|
|
535
|
+
!targets.some(target => !target.native && target.id === row.id)
|
|
536
|
+
));
|
|
373
537
|
}
|
|
374
538
|
} else {
|
|
375
539
|
for (const target of targets) {
|
|
@@ -543,6 +707,104 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
543
707
|
}
|
|
544
708
|
return jsonResponse({ selected, available, liveModelCounts });
|
|
545
709
|
}
|
|
710
|
+
if (url.pathname === "/api/model-presets" && req.method === "GET") {
|
|
711
|
+
// Preview without applying: rules evaluated against the CURRENT catalog, so the count the
|
|
712
|
+
// user sees is the count they would get.
|
|
713
|
+
const models = await fetchAllModels(config);
|
|
714
|
+
const byProvider = new Map<string, string[]>();
|
|
715
|
+
for (const m of models) {
|
|
716
|
+
const ids = byProvider.get(m.provider) ?? [];
|
|
717
|
+
ids.push(m.id);
|
|
718
|
+
byProvider.set(m.provider, ids);
|
|
719
|
+
}
|
|
720
|
+
const providers: Record<string, unknown> = {};
|
|
721
|
+
for (const [name, prov] of Object.entries(config.providers)) {
|
|
722
|
+
const preset = modelPresetFor(name);
|
|
723
|
+
if (!preset) continue;
|
|
724
|
+
const catalogIds = byProvider.get(name) ?? [];
|
|
725
|
+
const presetIds = materializeModelPreset(name, catalogIds);
|
|
726
|
+
providers[name] = {
|
|
727
|
+
mode: prov.modelPreset?.mode ?? "all",
|
|
728
|
+
...(prov.modelPreset?.appliedVersion !== undefined
|
|
729
|
+
? { appliedVersion: prov.modelPreset.appliedVersion }
|
|
730
|
+
: {}),
|
|
731
|
+
availableVersion: preset.version,
|
|
732
|
+
presetIds,
|
|
733
|
+
presetCount: presetIds.length,
|
|
734
|
+
totalCount: catalogIds.length,
|
|
735
|
+
...(prov.modelPreset?.fallback ? { fallback: prov.modelPreset.fallback } : {}),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return jsonResponse({ providers });
|
|
739
|
+
}
|
|
740
|
+
if (url.pathname === "/api/model-presets" && req.method === "PUT") {
|
|
741
|
+
let body: { provider?: unknown; mode?: unknown };
|
|
742
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
743
|
+
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
744
|
+
if (!provider || !hasOwnProvider(config.providers, provider)) {
|
|
745
|
+
return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
|
|
746
|
+
}
|
|
747
|
+
const mode = body.mode;
|
|
748
|
+
if (mode !== "preset" && mode !== "all" && mode !== "custom") {
|
|
749
|
+
return jsonResponse({ error: "mode must be preset, all, or custom" }, 400);
|
|
750
|
+
}
|
|
751
|
+
const target = config.providers[provider];
|
|
752
|
+
if (mode === "all") {
|
|
753
|
+
// Same effect as today's empty-list PUT: no allowlist, no marker to reconcile.
|
|
754
|
+
delete target.selectedModels;
|
|
755
|
+
delete target.modelPreset;
|
|
756
|
+
persistConfig(config);
|
|
757
|
+
return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() });
|
|
758
|
+
}
|
|
759
|
+
if (mode === "custom") {
|
|
760
|
+
// Keep whatever is selected; only the marker changes, so a user can pin their edits
|
|
761
|
+
// without the proxy re-materializing over them.
|
|
762
|
+
target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" };
|
|
763
|
+
persistConfig(config);
|
|
764
|
+
return jsonResponse({ ok: true, provider, mode, selected: [...(target.selectedModels ?? [])] });
|
|
765
|
+
}
|
|
766
|
+
if (!hasModelPreset(provider)) {
|
|
767
|
+
return jsonResponse({ error: `no model preset is shipped for provider '${provider}'` }, 400);
|
|
768
|
+
}
|
|
769
|
+
const models = await fetchAllModels(config);
|
|
770
|
+
const catalogIds = models.filter(m => m.provider === provider).map(m => m.id);
|
|
771
|
+
const presetIds = materializeModelPreset(provider, catalogIds);
|
|
772
|
+
const preset = modelPresetFor(provider)!;
|
|
773
|
+
if (presetIds.length === 0) {
|
|
774
|
+
// NEVER write an empty allowlist from a preset: empty means ALL, so it would silently
|
|
775
|
+
// un-curate instead of curating. Keep the previous selection and record the fallback so
|
|
776
|
+
// the next convergence can retry.
|
|
777
|
+
target.modelPreset = {
|
|
778
|
+
mode: "all",
|
|
779
|
+
appliedVersion: preset.version,
|
|
780
|
+
appliedAt: new Date().toISOString(),
|
|
781
|
+
fallback: "preset-empty",
|
|
782
|
+
};
|
|
783
|
+
persistConfig(config);
|
|
784
|
+
return jsonResponse({
|
|
785
|
+
ok: true,
|
|
786
|
+
provider,
|
|
787
|
+
mode: "all",
|
|
788
|
+
fallback: "preset-empty",
|
|
789
|
+
selected: [...(target.selectedModels ?? [])],
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
target.selectedModels = presetIds;
|
|
793
|
+
target.modelPreset = {
|
|
794
|
+
mode: "preset",
|
|
795
|
+
appliedVersion: preset.version,
|
|
796
|
+
appliedAt: new Date().toISOString(),
|
|
797
|
+
};
|
|
798
|
+
persistConfig(config);
|
|
799
|
+
return jsonResponse({
|
|
800
|
+
ok: true,
|
|
801
|
+
provider,
|
|
802
|
+
mode: "preset",
|
|
803
|
+
appliedVersion: preset.version,
|
|
804
|
+
selected: presetIds,
|
|
805
|
+
catalogRefresh: await convergeCodexCatalog(),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
546
808
|
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
|
|
547
809
|
let body: { provider?: unknown; models?: unknown };
|
|
548
810
|
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
@@ -556,6 +818,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
556
818
|
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
557
819
|
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
558
820
|
else delete config.providers[provider].selectedModels;
|
|
821
|
+
// Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the
|
|
822
|
+
// provider is in preset mode makes the selection theirs, and the proxy must never
|
|
823
|
+
// re-materialize over it afterwards.
|
|
824
|
+
markModelPresetDiverged(config.providers[provider]);
|
|
559
825
|
persistConfig(config);
|
|
560
826
|
const catalogRefresh = await convergeCodexCatalog();
|
|
561
827
|
return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });
|
|
@@ -97,6 +97,9 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR
|
|
|
97
97
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
98
98
|
import type { ManagementContext } from "./context";
|
|
99
99
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
100
|
+
import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials";
|
|
101
|
+
import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth";
|
|
102
|
+
import { maxWsFrameBytesConfigError, wsUpstreamConfigError } from "../../config/provider-validation";
|
|
100
103
|
|
|
101
104
|
type ProviderPatchApplication =
|
|
102
105
|
| { error: string }
|
|
@@ -108,6 +111,93 @@ type ProviderPatchApplication =
|
|
|
108
111
|
headersTouched: boolean;
|
|
109
112
|
};
|
|
110
113
|
|
|
114
|
+
const AI_STUDIO_REAUTH_ERROR = "Session expired or missing — re-authentication required";
|
|
115
|
+
const AI_STUDIO_PROBE_TIMEOUT_MS = 8_000;
|
|
116
|
+
const AI_STUDIO_PROBE_MODEL = "gemini-2.5-flash";
|
|
117
|
+
const AI_STUDIO_ORIGIN = "https://aistudio.google.com";
|
|
118
|
+
|
|
119
|
+
let aiStudioProbeFetchForTests: typeof fetch | undefined;
|
|
120
|
+
|
|
121
|
+
export function setAiStudioProbeFetchForTests(fetchImpl?: typeof fetch): void {
|
|
122
|
+
aiStudioProbeFetchForTests = fetchImpl;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isAiStudioHtmlSignIn(text: string): boolean {
|
|
126
|
+
const lower = text.trim().toLowerCase();
|
|
127
|
+
return lower.startsWith("<!doctype") || lower.startsWith("<html") || lower.includes("accounts.google.com/v3/signin");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function probeAiStudioLiveSession(
|
|
131
|
+
name: string,
|
|
132
|
+
prov: OcxProviderConfig,
|
|
133
|
+
): Promise<{ ok: boolean; latencyMs: number; authState?: "connected" | "checking" | "needs_reauth" | "unsupported"; message?: string; error?: string }> {
|
|
134
|
+
const credentials = resolveAiStudioCredentials(prov);
|
|
135
|
+
if (credentials.kind !== "ready") {
|
|
136
|
+
return { ok: false, latencyMs: 0, error: AI_STUDIO_REAUTH_ERROR };
|
|
137
|
+
}
|
|
138
|
+
if (process.platform !== "darwin") {
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
latencyMs: 0,
|
|
142
|
+
authState: "unsupported",
|
|
143
|
+
message: "AI Studio credentials configured; native login is unsupported on this platform",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const base = (prov.baseUrl || "https://alkalimakersuite-pa.clients6.google.com").replace(/\/+$/, "");
|
|
148
|
+
const url = base + "/v1internal:generateContent";
|
|
149
|
+
const jar = parseGoogleCookieJar(credentials.cookieHeader);
|
|
150
|
+
const headers = await buildAiStudioHeaders(jar, AI_STUDIO_ORIGIN);
|
|
151
|
+
const body = JSON.stringify({
|
|
152
|
+
model: AI_STUDIO_PROBE_MODEL,
|
|
153
|
+
contents: [{ role: "user", parts: [{ text: "ping" }] }],
|
|
154
|
+
generationConfig: { maxOutputTokens: 1 },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), AI_STUDIO_PROBE_TIMEOUT_MS);
|
|
159
|
+
const started = Date.now();
|
|
160
|
+
try {
|
|
161
|
+
const outboundProvider = aiStudioProbeFetchForTests
|
|
162
|
+
? { ...prov, fetch: aiStudioProbeFetchForTests }
|
|
163
|
+
: prov;
|
|
164
|
+
const response = await providerOutboundPost(name, outboundProvider, url, {
|
|
165
|
+
headers,
|
|
166
|
+
body,
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
});
|
|
169
|
+
const latencyMs = Date.now() - started;
|
|
170
|
+
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
171
|
+
const text = await response.text().catch(() => "");
|
|
172
|
+
|
|
173
|
+
if ((response.status >= 300 && response.status < 400) || response.status === 401 || response.status === 403) {
|
|
174
|
+
return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR };
|
|
175
|
+
}
|
|
176
|
+
if (contentType.includes("text/html") || isAiStudioHtmlSignIn(text)) {
|
|
177
|
+
return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR };
|
|
178
|
+
}
|
|
179
|
+
if (response.status !== 200) {
|
|
180
|
+
return { ok: false, latencyMs, error: "AI Studio connection probe failed" };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
JSON.parse(text);
|
|
184
|
+
} catch {
|
|
185
|
+
return { ok: false, latencyMs, error: "AI Studio connection probe failed" };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
latencyMs,
|
|
190
|
+
authState: "connected",
|
|
191
|
+
message: "AI Studio session verified",
|
|
192
|
+
};
|
|
193
|
+
} catch {
|
|
194
|
+
return { ok: false, latencyMs: Date.now() - started, error: "AI Studio connection probe failed" };
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
111
201
|
/**
|
|
112
202
|
* Apply the recognized PATCH field mask onto a provider copy. The caller runs this once
|
|
113
203
|
* for validation and again inside the config mutation lock against the newest provider,
|
|
@@ -206,6 +296,28 @@ function applyProviderPatchFields(
|
|
|
206
296
|
next.liveModels = rawBody.liveModels;
|
|
207
297
|
touched = true;
|
|
208
298
|
}
|
|
299
|
+
if (Object.hasOwn(rawBody, "wsUpstream")) {
|
|
300
|
+
const value = rawBody.wsUpstream;
|
|
301
|
+
if (value === null) {
|
|
302
|
+
delete next.wsUpstream;
|
|
303
|
+
} else {
|
|
304
|
+
const error = wsUpstreamConfigError(value);
|
|
305
|
+
if (error) return { error };
|
|
306
|
+
next.wsUpstream = value as boolean;
|
|
307
|
+
}
|
|
308
|
+
touched = true;
|
|
309
|
+
}
|
|
310
|
+
if (Object.hasOwn(rawBody, "maxWsFrameBytes")) {
|
|
311
|
+
const value = rawBody.maxWsFrameBytes;
|
|
312
|
+
if (value === null) {
|
|
313
|
+
delete next.maxWsFrameBytes;
|
|
314
|
+
} else {
|
|
315
|
+
const error = maxWsFrameBytesConfigError(value);
|
|
316
|
+
if (error) return { error };
|
|
317
|
+
next.maxWsFrameBytes = value as number;
|
|
318
|
+
}
|
|
319
|
+
touched = true;
|
|
320
|
+
}
|
|
209
321
|
if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) {
|
|
210
322
|
if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" };
|
|
211
323
|
if (typeof rawBody.xaiResponsesOptIn !== "boolean") {
|
|
@@ -482,6 +594,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
482
594
|
contextWindow: p.contextWindow,
|
|
483
595
|
modelContextWindows: p.modelContextWindows,
|
|
484
596
|
modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
|
|
597
|
+
wsUpstream: p.wsUpstream,
|
|
598
|
+
maxWsFrameBytes: p.maxWsFrameBytes,
|
|
485
599
|
modelSupportsServiceTier: p.modelSupportsServiceTier,
|
|
486
600
|
noStructuredOutputModels: p.noStructuredOutputModels,
|
|
487
601
|
upstreamHttpVersion: p.upstreamHttpVersion,
|
|
@@ -575,15 +689,26 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
575
689
|
let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
|
|
576
690
|
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
577
691
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
578
|
-
const
|
|
692
|
+
const submittedProvider = isPlainRecord(body.provider)
|
|
693
|
+
? structuredClone(body.provider) as Record<string, unknown>
|
|
694
|
+
: body.provider;
|
|
695
|
+
const submittedCredential = isPlainRecord(submittedProvider)
|
|
696
|
+
&& isPlainRecord(submittedProvider.azureCredential)
|
|
697
|
+
? submittedProvider.azureCredential as Record<string, unknown>
|
|
698
|
+
: undefined;
|
|
699
|
+
if (typeof submittedCredential?.managedIdentityClientId === "string") {
|
|
700
|
+
submittedCredential.managedIdentityClientId = submittedCredential.managedIdentityClientId.trim();
|
|
701
|
+
}
|
|
702
|
+
const providerError = providerManagementConfigError(name, submittedProvider);
|
|
579
703
|
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
580
|
-
const serviceTierError = providerServiceTierConfigError(name,
|
|
704
|
+
const serviceTierError = providerServiceTierConfigError(name, submittedProvider);
|
|
581
705
|
if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
|
|
582
|
-
const prov =
|
|
706
|
+
const prov = submittedProvider ? stripCodexRuntimeProviderFields(submittedProvider as OcxProviderConfig) : undefined;
|
|
583
707
|
// PATCH already clears on null; POST persisted the body as submitted, so a `null` here
|
|
584
708
|
// reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what
|
|
585
709
|
// "clear" means everywhere else.
|
|
586
710
|
if (prov && prov.upstreamHttpVersion === null) delete prov.upstreamHttpVersion;
|
|
711
|
+
if (prov && prov.wsUpstream === null) delete prov.wsUpstream;
|
|
587
712
|
if (!name || !prov?.adapter || !prov?.baseUrl) {
|
|
588
713
|
return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
|
|
589
714
|
}
|
|
@@ -628,6 +753,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
628
753
|
// erase hand-edited per-model prices from Logs/Usage estimates.
|
|
629
754
|
const existingCosts = config.providers[name]?.modelCosts;
|
|
630
755
|
if (existingCosts && !prov.modelCosts) prov.modelCosts = existingCosts;
|
|
756
|
+
// And to the per-provider account-failover opt-out (#2568d). `ProviderPayload` has no
|
|
757
|
+
// member for it either, so an add/edit save structurally cannot carry it — and dropping it
|
|
758
|
+
// silently ENABLES rotation, because activation is presence-driven once the knob is gone.
|
|
759
|
+
// An overwrite must not spend a second subscription account's quota as a side effect.
|
|
760
|
+
const existingFailover = config.providers[name]?.oauthAccountFailover;
|
|
761
|
+
if (existingFailover && !prov.oauthAccountFailover) prov.oauthAccountFailover = existingFailover;
|
|
631
762
|
// ...and to hand-edited context windows. `ProviderPayload` (gui/src/provider-payload.ts)
|
|
632
763
|
// has no member for either field, so the add/edit form structurally cannot send them:
|
|
633
764
|
// absence in the request means "not carried", never "the user deleted it". Deletion goes
|
|
@@ -838,6 +969,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
838
969
|
message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
|
|
839
970
|
});
|
|
840
971
|
}
|
|
972
|
+
if (prov.googleMode === "ai-studio-web" || name === "google-aistudio") {
|
|
973
|
+
return jsonResponse(await probeAiStudioLiveSession(name, prov));
|
|
974
|
+
}
|
|
841
975
|
if (prov.liveModels === false) {
|
|
842
976
|
// A static catalog has no live discovery endpoint to test. This is neither
|
|
843
977
|
// positive connectivity evidence nor an outage, and it must stay before
|
|
@@ -20,7 +20,7 @@ import { assemblePolicyCandidateEvidence } from "../../routing/compatibility/ass
|
|
|
20
20
|
import { activateLab, labActivationRequired } from "../../lib/lab-activation";
|
|
21
21
|
import { quotaEvidenceForCandidate } from "../../routing/quota";
|
|
22
22
|
import { routedProviderConfig } from "../../router";
|
|
23
|
-
import { saveConfigPreservingClaudeCode, getConfigDir } from "../../config";
|
|
23
|
+
import { deleteConfigTopLevelKey, saveConfigPreservingClaudeCode, getConfigDir } from "../../config";
|
|
24
24
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
25
25
|
import { isPlainRecord } from "./shared";
|
|
26
26
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
@@ -337,7 +337,7 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
|
|
|
337
337
|
const nextProfiles = { ...(config.routingProfiles ?? {}) };
|
|
338
338
|
delete nextProfiles[id];
|
|
339
339
|
if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles;
|
|
340
|
-
else
|
|
340
|
+
else deleteConfigTopLevelKey(config, "routingProfiles");
|
|
341
341
|
const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
342
342
|
saveConfigPreservingClaudeCodeSafe(config);
|
|
343
343
|
reconcileLiveStateStores();
|