@bitkyc08/opencodex 2.43.0 → 2.44.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 +25 -17
- package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
- package/gui/dist/assets/index-ltx3L-WS.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +23 -2
- package/src/adapters/cursor/native-exec.ts +63 -11
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/cursor/tool-schemas.ts +59 -0
- package/src/adapters/kiro.ts +54 -8
- package/src/adapters/openai-chat.ts +23 -6
- package/src/adapters/openai-responses.ts +3 -1
- package/src/adapters/opencode-go.ts +35 -0
- package/src/claude/agents-inject.ts +12 -5
- package/src/claude/desktop-3p-library.ts +89 -0
- package/src/claude/desktop-3p.ts +166 -95
- package/src/claude/desktop-discovery-inputs.ts +44 -0
- package/src/claude/desktop-profile.ts +1 -1
- package/src/claude/desktop-remote-store-artifact.ts +185 -0
- package/src/claude/desktop-remote-store-io.ts +91 -0
- package/src/claude/desktop-remote-store-state.ts +127 -0
- package/src/claude/desktop-remote-store.ts +269 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +1 -1
- package/src/claude/model-info.ts +16 -3
- package/src/cli/account-api.ts +7 -1
- package/src/cli/aside-profiles.ts +17 -0
- package/src/cli/capabilities.ts +39 -0
- package/src/cli/claude-agent-startup-sync.ts +3 -1
- package/src/cli/claude-desktop.ts +127 -18
- package/src/cli/connect.ts +27 -14
- package/src/cli/dispatch.ts +30 -16
- package/src/cli/export-command.ts +3 -1
- package/src/cli/index.ts +28 -16
- package/src/cli/integrations.ts +46 -12
- package/src/cli/opencode.ts +11 -4
- package/src/cli/runtime-api.ts +7 -2
- package/src/cli/uninstall-client-state.ts +78 -0
- package/src/client/connect.ts +476 -223
- package/src/client/hub-client.ts +81 -0
- package/src/client/hub-relay.ts +38 -6
- package/src/client/lifecycle-lock.ts +129 -0
- package/src/client/state.ts +71 -42
- package/src/clients/aside-profiles.ts +224 -0
- package/src/codex/account-lifecycle.ts +5 -9
- package/src/codex/auth-api.ts +50 -4
- package/src/codex/catalog/metadata.ts +0 -2
- package/src/codex/catalog/provider-fetch.ts +7 -5
- package/src/codex/catalog/sync.ts +88 -24
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +45 -14
- package/src/codex/journal.ts +6 -6
- package/src/codex/quota-refresh-outcome.ts +27 -0
- package/src/codex/shim.ts +7 -1
- package/src/combos/resolve.ts +10 -7
- package/src/config.ts +18 -5
- package/src/generated/compatibility-version.json +209 -105
- package/src/images/loop.ts +5 -2
- package/src/integrations/aside-profile-context.ts +270 -0
- package/src/integrations/aside-profile-journal.ts +229 -0
- package/src/integrations/aside-profiles.ts +176 -0
- package/src/integrations/catalog-refresh.ts +40 -0
- package/src/integrations/config-io.ts +16 -1
- package/src/integrations/owned-refresh.ts +14 -2
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +3 -1
- package/src/lab/fabric/producer-isolate.ts +84 -46
- package/src/lib/account-selection-events.ts +32 -0
- package/src/lib/errors.ts +9 -1
- package/src/lib/provider-outbound.ts +2 -45
- package/src/lib/proxy-env.ts +67 -0
- package/src/oauth/account-quota-rank.ts +9 -1
- package/src/oauth/anthropic-routing.ts +91 -23
- package/src/oauth/generic-account-failover.ts +15 -53
- package/src/oauth/index.ts +67 -68
- package/src/oauth/pool-settings-capability.ts +2 -1
- package/src/oauth/store.ts +91 -7
- package/src/oauth/types.ts +8 -0
- package/src/providers/api-key-selection.ts +110 -0
- package/src/providers/api-keys.ts +38 -31
- package/src/providers/context-cap.ts +27 -3
- package/src/providers/key-failover.ts +27 -17
- package/src/providers/provider-id-rewrite.ts +10 -8
- package/src/providers/registry.ts +1 -0
- package/src/responses/custom-tool-compat.ts +10 -0
- package/src/responses/function-call-compat.ts +173 -0
- package/src/responses/namespace-tool-compat.ts +27 -4
- package/src/responses/parser.ts +8 -0
- package/src/responses/task-input.ts +36 -0
- package/src/responses/tool-name-aliases.ts +79 -0
- package/src/router.ts +2 -0
- package/src/routing/capability.ts +10 -3
- package/src/routing/compatibility/assemble.ts +16 -1
- package/src/routing/evaluator.ts +5 -1
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-native.ts +44 -9
- package/src/server/claude-messages.ts +71 -36
- package/src/server/grok-responses-snapshot-repair.ts +333 -0
- package/src/server/index.ts +26 -10
- package/src/server/management/account-selection-stream.ts +70 -0
- package/src/server/management/aside-profile-routes.ts +231 -0
- package/src/server/management/config-routes.ts +36 -41
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +61 -3
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/model-routes.ts +20 -9
- package/src/server/management/model-rows.ts +10 -2
- package/src/server/management/oauth-account-routes.ts +7 -0
- package/src/server/management/provider-routes.ts +10 -8
- package/src/server/management/route-registry.ts +20 -2
- package/src/server/management-auth.ts +16 -0
- package/src/server/relay-eager.ts +17 -5
- package/src/server/relay.ts +43 -4
- package/src/server/request-log.ts +29 -1
- package/src/server/responses/agent-task-recovery-cache.ts +11 -0
- package/src/server/responses/agent-task-recovery.ts +24 -4
- package/src/server/responses/codex-ws-pool.ts +4 -4
- package/src/server/responses/codex-ws-session.ts +2 -2
- package/src/server/responses/collaboration.ts +2 -1
- package/src/server/responses/combo-stream-preflight.ts +26 -7
- package/src/server/responses/core.ts +668 -151
- package/src/server/responses/fetch-helpers.ts +6 -1
- package/src/server/responses/ws-upstream.ts +8 -3
- package/src/server/responses-custom-tool-repair.ts +29 -9
- package/src/server/responses-function-tool-repair.ts +183 -0
- package/src/server/responses-snapshot-codec.ts +14 -0
- package/src/server/responses-snapshot-repair.ts +1 -13
- package/src/server/responses-undeclared-tool-guard.ts +1 -68
- package/src/server/sse-payload-rewrite.ts +3 -1
- package/src/service.ts +6 -2
- package/src/types/config.ts +23 -16
- package/src/types/provider.ts +14 -3
- package/src/types/request.ts +2 -0
- package/src/usage/log.ts +9 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
- package/gui/dist/assets/index-Djowl68T.js +0 -112
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { redactSecretString } from "../../lib/redact";
|
|
2
|
+
import { ClientPathError } from "../../clients/config-export";
|
|
3
|
+
import { IntegrationMutationBusyError } from "../../integrations/mutation-flight";
|
|
4
|
+
import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock";
|
|
5
|
+
import {
|
|
6
|
+
getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles,
|
|
7
|
+
type AsideProfilesInput,
|
|
8
|
+
} from "../../integrations/aside-profiles";
|
|
9
|
+
import {
|
|
10
|
+
listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation,
|
|
11
|
+
asideOperationMatchesCurrent,
|
|
12
|
+
} from "../../integrations/aside-profile-journal";
|
|
13
|
+
import type { WriteRefused } from "../../integrations/writer";
|
|
14
|
+
import type { ManagementContext } from "./context";
|
|
15
|
+
import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
16
|
+
import { jsonResponse } from "../auth-cors";
|
|
17
|
+
|
|
18
|
+
export interface AsideProfileRouteOptions {
|
|
19
|
+
input: () => AsideProfilesInput;
|
|
20
|
+
failure: (result: WriteRefused) => Response;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; }
|
|
24
|
+
|
|
25
|
+
const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside";
|
|
26
|
+
const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles";
|
|
27
|
+
|
|
28
|
+
function profileId(ctx: ManagementContext): number | undefined {
|
|
29
|
+
const raw = ctx.url.searchParams.get("profile");
|
|
30
|
+
if (raw === null) return undefined;
|
|
31
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
|
|
32
|
+
throw new ProfileQueryError("profile must be a nonnegative integer account ID");
|
|
33
|
+
}
|
|
34
|
+
return Number(raw);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorResponse(error: unknown, ctx: ManagementContext): Response {
|
|
38
|
+
rethrowManagementBodyTooLarge(error);
|
|
39
|
+
const detail = error as { status?: unknown; code?: unknown } | null;
|
|
40
|
+
const busy = error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError;
|
|
41
|
+
const status = busy ? 409 : typeof detail?.status === "number" && [400,404,409,410,500].includes(detail.status)
|
|
42
|
+
? detail.status : error instanceof ClientPathError ? 409 : 500;
|
|
43
|
+
const code = busy ? "integration_mutation_busy"
|
|
44
|
+
: typeof detail?.code === "string" ? detail.code : "aside_profile_error";
|
|
45
|
+
return jsonResponse({
|
|
46
|
+
error: redactSecretString(error instanceof Error ? error.message : "Aside profile operation failed"),
|
|
47
|
+
code, clientId: "aside",
|
|
48
|
+
}, status, ctx.req, ctx.config);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readProfileBody(req: Request, optional = false): Promise<unknown> {
|
|
56
|
+
try { return await (optional ? readOptionalManagementJsonBody(req) : readManagementJsonBody(req)); }
|
|
57
|
+
catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateClientSelector(ctx: ManagementContext): void {
|
|
61
|
+
const client = ctx.url.searchParams.get("client");
|
|
62
|
+
if (client !== null && client !== "aside") throw new ProfileQueryError("client/profile selectors must identify Aside");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Dedicated scoped paths fail closed even when a newer client reaches an older server. */
|
|
66
|
+
function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; action?: string } {
|
|
67
|
+
const prefix = "/api/client-integrations/aside/profiles/";
|
|
68
|
+
if (!ctx.url.pathname.startsWith(prefix)) return { ctx };
|
|
69
|
+
validateClientSelector(ctx);
|
|
70
|
+
const parts = ctx.url.pathname.slice(prefix.length).split("/");
|
|
71
|
+
const url = new URL(ctx.url);
|
|
72
|
+
if (parts.length === 1 && parts[0] === "journal") {
|
|
73
|
+
if (url.searchParams.has("profile")) throw new ProfileQueryError("Use a profile-specific journal path");
|
|
74
|
+
url.pathname = "/api/client-integrations/journal";
|
|
75
|
+
url.searchParams.set("client", "aside");
|
|
76
|
+
return { ctx: { ...ctx, url }, action: "journal" };
|
|
77
|
+
}
|
|
78
|
+
if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) {
|
|
79
|
+
throw new ProfileQueryError("Invalid Aside profile path");
|
|
80
|
+
}
|
|
81
|
+
const prior = url.searchParams.get("profile");
|
|
82
|
+
if (prior !== null && prior !== parts[0]) throw new ProfileQueryError("Conflicting Aside profile selectors");
|
|
83
|
+
url.searchParams.set("profile", parts[0]);
|
|
84
|
+
url.searchParams.set("client", "aside");
|
|
85
|
+
url.pathname = parts[1] ? `/api/client-integrations/${parts[1]}` : "/api/client-integrations/aside";
|
|
86
|
+
return { ctx: { ...ctx, url }, action: parts[1] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Own only Aside status/toggle paths; other clients keep the existing adapter. */
|
|
90
|
+
export async function handleAsideProfileRoutes(
|
|
91
|
+
ctx: ManagementContext, options: AsideProfileRouteOptions,
|
|
92
|
+
): Promise<Response | null> {
|
|
93
|
+
if (ctx.url.pathname !== ASIDE_INTEGRATION_PATH
|
|
94
|
+
&& !ctx.url.pathname.startsWith(`${ASIDE_INTEGRATION_PATH}/`)) return null;
|
|
95
|
+
try {
|
|
96
|
+
const normalized = nestedProfileContext(ctx);
|
|
97
|
+
ctx = normalized.ctx;
|
|
98
|
+
const { req, url } = ctx;
|
|
99
|
+
validateClientSelector(ctx);
|
|
100
|
+
const id = profileId(ctx);
|
|
101
|
+
if (normalized.action === "journal") {
|
|
102
|
+
if (req.method === "GET") return asideJournalResponse(ctx, "aside", options);
|
|
103
|
+
if (req.method === "DELETE") {
|
|
104
|
+
const opId = url.searchParams.get("opId")?.trim();
|
|
105
|
+
if (!opId) throw new ProfileQueryError("opId is required");
|
|
106
|
+
return asideJournalDeleteResponse(ctx, opId, options);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (normalized.action === "restore") {
|
|
111
|
+
if (req.method !== "POST") return null;
|
|
112
|
+
const body = await readProfileBody(req);
|
|
113
|
+
if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim()
|
|
114
|
+
|| (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request");
|
|
115
|
+
return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options);
|
|
116
|
+
}
|
|
117
|
+
if (url.pathname === "/api/client-integrations/aside/sync") {
|
|
118
|
+
if (req.method !== "POST") return null;
|
|
119
|
+
if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles");
|
|
120
|
+
const body = await readProfileBody(req, true);
|
|
121
|
+
if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object");
|
|
122
|
+
const results = await refreshAsideProfiles(options.input());
|
|
123
|
+
const ok = results.every(result => result.ok);
|
|
124
|
+
return jsonResponse({ ok, clientId: "aside", results }, ok ? 200 : 207, req, ctx.config);
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname !== ASIDE_INTEGRATION_PATH && url.pathname !== ASIDE_PROFILES_PATH) return null;
|
|
127
|
+
if (req.method !== "GET" && req.method !== "PUT") return null;
|
|
128
|
+
if (url.pathname.endsWith("/profiles") && id !== undefined) throw new ProfileQueryError("Use a profile-specific path");
|
|
129
|
+
if (req.method === "GET") {
|
|
130
|
+
const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id);
|
|
131
|
+
return jsonResponse(state, 200, req, ctx.config);
|
|
132
|
+
}
|
|
133
|
+
const body = await readProfileBody(req);
|
|
134
|
+
if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean");
|
|
135
|
+
if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean");
|
|
136
|
+
if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration");
|
|
137
|
+
const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true });
|
|
138
|
+
if (id !== undefined) {
|
|
139
|
+
const result = batch.results[0];
|
|
140
|
+
if (!result) throw new Error("Aside profile mutation returned no result");
|
|
141
|
+
return result.ok ? jsonResponse(result, 200, req, ctx.config) : options.failure(result);
|
|
142
|
+
}
|
|
143
|
+
return jsonResponse(batch, batch.ok ? 200 : 207, req, ctx.config);
|
|
144
|
+
} catch (error) { return errorResponse(error, ctx); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Profile-qualified history, including source-store provenance for imported legacy entries. */
|
|
148
|
+
export async function asideJournalResponse(
|
|
149
|
+
ctx: ManagementContext, requestedClient: string | null, options: AsideProfileRouteOptions,
|
|
150
|
+
): Promise<Response | null> {
|
|
151
|
+
if (requestedClient === null && !ctx.url.searchParams.has("profile")) return null;
|
|
152
|
+
if (requestedClient !== null && requestedClient !== "aside") {
|
|
153
|
+
return ctx.url.searchParams.has("profile") ? errorResponse(new ProfileQueryError("profile applies only to Aside"), ctx) : null;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const id = profileId(ctx);
|
|
157
|
+
if (id !== undefined && requestedClient !== "aside") throw new ProfileQueryError("profile requires client=aside");
|
|
158
|
+
const input = options.input();
|
|
159
|
+
const aside = await listAsideOperations(input, id);
|
|
160
|
+
const rows = [...aside].sort((a, b) => b.entry.at.localeCompare(a.entry.at));
|
|
161
|
+
const newest = new Map<string, string>();
|
|
162
|
+
const ownerKey = (row: typeof rows[number]) => `${row.entry.clientId}:${row.profileId ?? row.entry.configPath}`;
|
|
163
|
+
for (const row of rows) if (!newest.has(ownerKey(row))) newest.set(ownerKey(row), row.entry.opId);
|
|
164
|
+
const operations = rows.map(row => {
|
|
165
|
+
const { entry, store } = row;
|
|
166
|
+
const snapshot = store.readSnapshot(entry).kind;
|
|
167
|
+
const latest = newest.get(ownerKey(row)) === entry.opId;
|
|
168
|
+
return {
|
|
169
|
+
opId: entry.opId, clientId: entry.clientId, kind: entry.kind, at: entry.at,
|
|
170
|
+
configPath: entry.configPath, snapshot,
|
|
171
|
+
...(row.profileId !== undefined ? { profileId: row.profileId } : {}),
|
|
172
|
+
undoable: snapshot !== "expired" && latest && row.profileId !== undefined && asideOperationMatchesCurrent(input, row),
|
|
173
|
+
deletable: !latest,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
return jsonResponse({ operations }, 200, ctx.req, ctx.config);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return requestedClient === null && !ctx.url.searchParams.has("profile") ? null : errorResponse(error, ctx);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function asideRestoreResponse(
|
|
183
|
+
ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions,
|
|
184
|
+
): Promise<Response | null> {
|
|
185
|
+
try {
|
|
186
|
+
validateClientSelector(ctx);
|
|
187
|
+
const id = profileId(ctx);
|
|
188
|
+
const input = options.input();
|
|
189
|
+
const rootEntry = input.store?.findOperation(body.opId);
|
|
190
|
+
if (rootEntry && rootEntry.clientId !== "aside") {
|
|
191
|
+
if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation");
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const operation = await findAsideOperation(input, body.opId, id);
|
|
195
|
+
if (!operation) {
|
|
196
|
+
if (id === undefined) return null;
|
|
197
|
+
return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config);
|
|
198
|
+
}
|
|
199
|
+
const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId });
|
|
200
|
+
return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile")
|
|
203
|
+
&& options.input().store?.findOperation(body.opId)?.clientId !== "aside") return null;
|
|
204
|
+
return errorResponse(error, ctx);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function asideJournalDeleteResponse(
|
|
209
|
+
ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions,
|
|
210
|
+
): Promise<Response | null> {
|
|
211
|
+
try {
|
|
212
|
+
validateClientSelector(ctx);
|
|
213
|
+
const id = profileId(ctx);
|
|
214
|
+
const input = options.input();
|
|
215
|
+
const rootEntry = input.store?.findOperation(opId);
|
|
216
|
+
if (rootEntry && rootEntry.clientId !== "aside") {
|
|
217
|
+
if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation");
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const operation = await findAsideOperation(input, opId, id);
|
|
221
|
+
if (!operation) {
|
|
222
|
+
if (id === undefined) return null;
|
|
223
|
+
return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config);
|
|
224
|
+
}
|
|
225
|
+
return jsonResponse(await deleteAsideOperation(input, { opId, profileId: operation.profileId, principal: ctx.principal ?? "admin-token" }), 200, ctx.req, ctx.config);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile")
|
|
228
|
+
&& options.input().store?.findOperation(opId)?.clientId !== "aside") return null;
|
|
229
|
+
return errorResponse(error, ctx);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IntegrationClientId } from "../../integrations/registry";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import type { CatalogModel } from "../../codex/catalog";
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
deleteConfigTopLevelKey,
|
|
9
10
|
hasOwnProvider,
|
|
10
11
|
isValidProviderName,
|
|
12
|
+
loadConfig,
|
|
11
13
|
multiAgentGuidanceEnabled,
|
|
12
14
|
providerBaseUrlConfigError,
|
|
13
15
|
providerHeadersConfigError,
|
|
@@ -151,10 +153,11 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
|
|
|
151
153
|
|
|
152
154
|
/** One client's outcome from a fan-out sync. Absent from the list means "left alone". */
|
|
153
155
|
interface ClientIntegrationSyncOutcome {
|
|
154
|
-
readonly client: "grok" | "claude-desktop" |
|
|
156
|
+
readonly client: "grok" | "claude-desktop" | IntegrationClientId;
|
|
155
157
|
readonly ok: boolean;
|
|
156
158
|
readonly changed?: boolean;
|
|
157
159
|
readonly reason?: string;
|
|
160
|
+
readonly profileId?: number;
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
/**
|
|
@@ -171,9 +174,10 @@ interface ClientIntegrationSyncOutcome {
|
|
|
171
174
|
* does not fail the sync: Codex is the one that matters for routing, and a broken Grok file
|
|
172
175
|
* should surface as a warning, not as a 500 on a command that did its main job.
|
|
173
176
|
*/
|
|
174
|
-
async function syncEnabledClientIntegrations(
|
|
177
|
+
export async function syncEnabledClientIntegrations(
|
|
175
178
|
port: number | undefined,
|
|
176
179
|
config: OcxConfig,
|
|
180
|
+
deps: Pick<ManagementContext["deps"], "fetchAllModels" | "writeDesktop3pConfig"> = {},
|
|
177
181
|
): Promise<ClientIntegrationSyncOutcome[]> {
|
|
178
182
|
if (port === undefined) return [];
|
|
179
183
|
const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state");
|
|
@@ -196,49 +200,40 @@ async function syncEnabledClientIntegrations(
|
|
|
196
200
|
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
197
201
|
const { desktopVisibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog");
|
|
198
202
|
const { fetchAllModels } = await import("../management-api");
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
routed,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
|
|
204
|
+
// Discovery admits a concurrent OFF or settings edit. Re-read outside C:
|
|
205
|
+
// the writer facade owns L and its final desired-state check under L→C.
|
|
206
|
+
const latest = loadConfig();
|
|
207
|
+
if (claudeDesktopIntegrationEnabled(latest)) {
|
|
208
|
+
const routed = filterCatalogVisibleModels(models, latest)
|
|
209
|
+
.map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow }));
|
|
210
|
+
const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
|
|
211
|
+
port,
|
|
212
|
+
[...desktopVisibleNativeSlugs(latest)],
|
|
213
|
+
routed,
|
|
214
|
+
latest.apiKeys?.[0]?.key,
|
|
215
|
+
"static",
|
|
216
|
+
latest.claudeCode?.desktopProfile,
|
|
217
|
+
nativeContextLimits(latest),
|
|
218
|
+
);
|
|
219
|
+
out.push(r.written
|
|
220
|
+
? { client: "claude-desktop", ok: true, changed: true }
|
|
221
|
+
: { client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" });
|
|
222
|
+
}
|
|
213
223
|
} catch (error) {
|
|
214
224
|
out.push({ client: "claude-desktop", ok: false, reason: error instanceof Error ? error.message : String(error) });
|
|
215
225
|
}
|
|
216
226
|
}
|
|
217
227
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
port,
|
|
228
|
-
});
|
|
229
|
-
if (result) {
|
|
230
|
-
out.push(result.ok
|
|
231
|
-
? {
|
|
232
|
-
client: "mcode",
|
|
233
|
-
ok: true,
|
|
234
|
-
changed: result.changed === true,
|
|
235
|
-
...(result.reason ? { reason: result.reason } : {}),
|
|
236
|
-
}
|
|
237
|
-
: { client: "mcode", ok: false, reason: result.reason });
|
|
238
|
-
}
|
|
239
|
-
} catch (error) {
|
|
240
|
-
out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) });
|
|
241
|
-
}
|
|
228
|
+
const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh");
|
|
229
|
+
out.push(...await refreshOwnedCatalogIntegrations({
|
|
230
|
+
models: async () => {
|
|
231
|
+
const { loadExportModels } = await import("./model-rows");
|
|
232
|
+
return loadExportModels(config);
|
|
233
|
+
},
|
|
234
|
+
config,
|
|
235
|
+
port,
|
|
236
|
+
}, ["mcode", "pi", "aside"]));
|
|
242
237
|
|
|
243
238
|
return out;
|
|
244
239
|
}
|
|
@@ -651,7 +646,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
651
646
|
// for the on-demand command. Codex goes first because the others read its catalog.
|
|
652
647
|
const integrations = result.status === "refused"
|
|
653
648
|
? []
|
|
654
|
-
: await syncEnabledClientIntegrations(runtime?.port, config);
|
|
649
|
+
: await syncEnabledClientIntegrations(runtime?.port, config, deps);
|
|
655
650
|
const status = result.status === "refused" ? 409 : (result.status === "skipped" || result.ok ? 200 : 500);
|
|
656
651
|
return jsonResponse({
|
|
657
652
|
...attachStaleAppServerHint(result),
|
|
@@ -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, ManagementSessionControl } from "../management-auth";
|
|
8
8
|
import type { CatalogModel } from "../../codex/catalog";
|
|
9
|
+
import type { refreshOwnedCatalogIntegrations } from "../../integrations/catalog-refresh";
|
|
9
10
|
import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
|
|
10
11
|
import type { injectGrokConfig } from "../../grok/inject";
|
|
11
12
|
import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
|
|
@@ -20,6 +21,8 @@ import type {
|
|
|
20
21
|
} from "../../codex/app-server-restart-service";
|
|
21
22
|
|
|
22
23
|
export interface ManagementApiDeps {
|
|
24
|
+
/** Isolates automatic owned-client writes in route tests. */
|
|
25
|
+
refreshOwnedCatalogIntegrations?: typeof refreshOwnedCatalogIntegrations;
|
|
23
26
|
/** Platform seam for capability projections; does not alter host-level startup behavior. */
|
|
24
27
|
platform?: NodeJS.Platform;
|
|
25
28
|
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* Design of record: devlog/_fin/260802_client_toggle_api/040_wp4_management_api.md.
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
|
+
import { saveConfigPreservingClaudeCode } from "../../config";
|
|
13
|
+
import { listAsideProfileStates, type AsideProfilesInput } from "../../integrations/aside-profiles";
|
|
14
|
+
import {
|
|
15
|
+
handleAsideProfileRoutes, asideJournalResponse, asideRestoreResponse,
|
|
16
|
+
asideJournalDeleteResponse, type AsideProfileRouteOptions,
|
|
17
|
+
} from "./aside-profile-routes";
|
|
12
18
|
import type { IntegrationIO } from "../../integrations/config-io";
|
|
13
19
|
import { matchesOperationResult } from "../../integrations/journal";
|
|
14
20
|
import {
|
|
@@ -41,6 +47,8 @@ import { loadExportModels } from "./model-rows";
|
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/";
|
|
50
|
+
const INTEGRATION_COLLECTION_PATH = "/api/client-integrations";
|
|
51
|
+
const INTEGRATION_HISTORY_PATHS = ["/api/client-integrations/journal", "/api/client-integrations/restore"];
|
|
44
52
|
export { INTEGRATION_MUTATION_TERMINAL_MS };
|
|
45
53
|
|
|
46
54
|
type IntegrationStateRecord = Awaited<ReturnType<typeof readIntegrationState>>;
|
|
@@ -85,6 +93,7 @@ export interface IntegrationJournalRow {
|
|
|
85
93
|
* is what a user reaches for right after the mistake.
|
|
86
94
|
*/
|
|
87
95
|
deletable: boolean;
|
|
96
|
+
profileId?: number;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export interface IntegrationToggleBody {
|
|
@@ -181,6 +190,23 @@ function integrationStore(): IntegrationStateStore {
|
|
|
181
190
|
return integrationMutationTestHooks?.store ?? createIntegrationStateStore();
|
|
182
191
|
}
|
|
183
192
|
|
|
193
|
+
function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions {
|
|
194
|
+
let input: AsideProfilesInput | undefined;
|
|
195
|
+
return {
|
|
196
|
+
input: () => input ??= {
|
|
197
|
+
config: ctx.config,
|
|
198
|
+
port: Number(ctx.url.port) || ctx.config.port,
|
|
199
|
+
models: () => loadExportModels(ctx.config),
|
|
200
|
+
store: integrationStore(),
|
|
201
|
+
...pathOverrides(),
|
|
202
|
+
io: integrationMutationTestHooks?.io,
|
|
203
|
+
lockSeams: integrationMutationTestHooks?.lockSeams,
|
|
204
|
+
persistConfig: ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode,
|
|
205
|
+
},
|
|
206
|
+
failure: result => writerFailureResponse("aside", result, ctx),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
184
210
|
async function buildIntegrationWriteInput(
|
|
185
211
|
clientId: IntegrationClientId,
|
|
186
212
|
ctx: ManagementContext,
|
|
@@ -331,6 +357,8 @@ async function handleJournalDelete(ctx: ManagementContext): Promise<Response> {
|
|
|
331
357
|
code: "invalid_op_id",
|
|
332
358
|
}, 400, req, ctx.config);
|
|
333
359
|
}
|
|
360
|
+
const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx));
|
|
361
|
+
if (aside) return aside;
|
|
334
362
|
try {
|
|
335
363
|
const store = integrationStore();
|
|
336
364
|
const operation = store.findOperation(opId);
|
|
@@ -392,6 +420,14 @@ async function handleJournalDelete(ctx: ManagementContext): Promise<Response> {
|
|
|
392
420
|
|
|
393
421
|
export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
394
422
|
const { req, url } = ctx;
|
|
423
|
+
const profileOptions = asideOptions(ctx);
|
|
424
|
+
const aside = await handleAsideProfileRoutes(ctx, profileOptions);
|
|
425
|
+
if (aside) return aside;
|
|
426
|
+
if (url.searchParams.has("profile")
|
|
427
|
+
&& (url.pathname === INTEGRATION_COLLECTION_PATH || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX))
|
|
428
|
+
&& !INTEGRATION_HISTORY_PATHS.includes(url.pathname)) {
|
|
429
|
+
return jsonResponse({ error: "profile applies only to Aside", code: "invalid_aside_profile" }, 400, req, ctx.config);
|
|
430
|
+
}
|
|
395
431
|
|
|
396
432
|
if (url.pathname === "/api/client-integrations" && req.method === "GET") {
|
|
397
433
|
try {
|
|
@@ -408,8 +444,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
408
444
|
* later one silently — a duplicate that could only ever hide a
|
|
409
445
|
* disagreement, never surface it.
|
|
410
446
|
*/
|
|
411
|
-
const clients = INTEGRATION_CLIENT_IDS.map(clientId =>
|
|
412
|
-
|
|
447
|
+
const clients = await Promise.all(INTEGRATION_CLIENT_IDS.map(async clientId => {
|
|
448
|
+
if (clientId === "aside") {
|
|
449
|
+
try { return await listAsideProfileStates({ ...profileOptions.input(), models }); }
|
|
450
|
+
catch { /* Existing status projection retains a safe unresolved-path diagnostic. */ }
|
|
451
|
+
}
|
|
452
|
+
return readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() });
|
|
453
|
+
}));
|
|
413
454
|
return jsonResponse({ clients } satisfies IntegrationStateListEnvelope, 200, req, ctx.config);
|
|
414
455
|
} catch (error) {
|
|
415
456
|
return internalErrorResponse(error, ctx);
|
|
@@ -426,6 +467,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
426
467
|
if (requestedClient !== null && !isIntegrationClientId(requestedClient)) {
|
|
427
468
|
return invalidClientResponse(ctx);
|
|
428
469
|
}
|
|
470
|
+
const asideJournal = await asideJournalResponse(ctx, requestedClient, profileOptions);
|
|
471
|
+
if (asideJournal) return asideJournal;
|
|
429
472
|
try {
|
|
430
473
|
const store = integrationStore();
|
|
431
474
|
const storedOperations = store.listOperations(requestedClient ?? undefined);
|
|
@@ -435,7 +478,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
435
478
|
newestByClient.set(operation.clientId, operation.opId);
|
|
436
479
|
}
|
|
437
480
|
}
|
|
438
|
-
|
|
481
|
+
let operations: IntegrationJournalRow[] = storedOperations.map(operation => {
|
|
439
482
|
/*
|
|
440
483
|
* Resolved against the DISK, not read off the row.
|
|
441
484
|
*
|
|
@@ -481,6 +524,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
481
524
|
deletable: newestByClient.get(operation.clientId) !== operation.opId,
|
|
482
525
|
};
|
|
483
526
|
});
|
|
527
|
+
if (requestedClient === null) {
|
|
528
|
+
const profiles = await asideJournalResponse(ctx, "aside", profileOptions);
|
|
529
|
+
if (profiles?.ok) {
|
|
530
|
+
const body = await profiles.json() as IntegrationJournalEnvelope;
|
|
531
|
+
operations = [...operations.filter(row => row.clientId !== "aside"), ...body.operations]
|
|
532
|
+
.sort((a, b) => b.at.localeCompare(a.at));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
484
535
|
return jsonResponse({ operations } satisfies IntegrationJournalEnvelope, 200, req, ctx.config);
|
|
485
536
|
} catch (error) {
|
|
486
537
|
return internalErrorResponse(error, ctx);
|
|
@@ -506,6 +557,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
506
557
|
|
|
507
558
|
const opId = parsed.opId.trim();
|
|
508
559
|
const confirmDrift = parsed.confirmDrift ?? false;
|
|
560
|
+
const asideRestore = await asideRestoreResponse(ctx, { opId, confirmDrift }, profileOptions);
|
|
561
|
+
if (asideRestore) return asideRestore;
|
|
509
562
|
let restoreClientId: IntegrationClientId | undefined;
|
|
510
563
|
try {
|
|
511
564
|
const store = integrationStore();
|
|
@@ -570,6 +623,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
570
623
|
const requestedClient = decodeClientPath(url.pathname);
|
|
571
624
|
if (requestedClient === null) return null;
|
|
572
625
|
if (!isIntegrationClientId(requestedClient)) return invalidClientResponse(ctx);
|
|
626
|
+
// Canonical Aside paths are owned above. Never decode an alternate spelling
|
|
627
|
+
// into the legacy single-account writer or bypass profile policy/guards.
|
|
628
|
+
if (requestedClient === "aside") {
|
|
629
|
+
return jsonResponse({ error: "Use the canonical Aside profile path", code: "invalid_aside_profile_path" }, 400, req, ctx.config);
|
|
630
|
+
}
|
|
573
631
|
|
|
574
632
|
if (req.method === "GET") {
|
|
575
633
|
try {
|
|
@@ -111,6 +111,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
111
111
|
const logs = filterRequestLogs(all, url.searchParams);
|
|
112
112
|
return jsonResponse({
|
|
113
113
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
114
|
+
generatedAt: Date.now(),
|
|
114
115
|
total,
|
|
115
116
|
logs: logs.map(requestLogDto),
|
|
116
117
|
});
|
|
@@ -183,6 +183,17 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
183
183
|
// bypass this seam with a dynamic config import — doing so replaced a user's
|
|
184
184
|
// ~/.opencodex/config.json with the `existing-uuid` test fixture.
|
|
185
185
|
const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
186
|
+
const convergeVisibleCatalogs = async () => {
|
|
187
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
188
|
+
const refresh = deps.refreshOwnedCatalogIntegrations
|
|
189
|
+
?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations;
|
|
190
|
+
const clientIntegrations = await refresh({
|
|
191
|
+
config,
|
|
192
|
+
port: Number(url.port) || config.port,
|
|
193
|
+
models: () => loadExportModels(config),
|
|
194
|
+
});
|
|
195
|
+
return { catalogRefresh, clientIntegrations };
|
|
196
|
+
};
|
|
186
197
|
|
|
187
198
|
if (url.pathname === "/api/model-discovery" && req.method === "GET") {
|
|
188
199
|
const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
|
|
@@ -518,8 +529,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
518
529
|
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
519
530
|
config.disabledModels = disabled;
|
|
520
531
|
persistConfig(config);
|
|
521
|
-
|
|
522
|
-
return jsonResponse({ ok: true, disabled, catalogRefresh });
|
|
532
|
+
return jsonResponse({ ok: true, disabled, ...await convergeVisibleCatalogs() });
|
|
523
533
|
}
|
|
524
534
|
|
|
525
535
|
// One user-facing visibility switch spans two persisted filters: a provider allowlist and the
|
|
@@ -566,7 +576,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
566
576
|
}
|
|
567
577
|
const id = value.id.trim();
|
|
568
578
|
const native = value.native === true;
|
|
569
|
-
|
|
579
|
+
const configuredOpenAiCustom = provider === "openai" && !native && providerConfig
|
|
580
|
+
&& (config.customModels ?? []).some(model => model.provider === provider && model.modelId === id);
|
|
581
|
+
if (!id || (native && (provider !== "openai" || !supportedNative.has(id)))
|
|
582
|
+
|| (provider === "openai" && !native && !configuredOpenAiCustom)) {
|
|
570
583
|
return jsonResponse({ error: "invalid model visibility target" }, 400);
|
|
571
584
|
}
|
|
572
585
|
const key = `${native ? "native" : "routed"}:${id}`;
|
|
@@ -641,8 +654,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
641
654
|
|
|
642
655
|
config.disabledModels = disabled;
|
|
643
656
|
persistConfig(config);
|
|
644
|
-
|
|
645
|
-
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, catalogRefresh });
|
|
657
|
+
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, ...await convergeVisibleCatalogs() });
|
|
646
658
|
}
|
|
647
659
|
|
|
648
660
|
if (url.pathname === "/api/custom-models" && req.method === "GET") {
|
|
@@ -852,7 +864,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
852
864
|
delete target.selectedModels;
|
|
853
865
|
delete target.modelPreset;
|
|
854
866
|
persistConfig(config);
|
|
855
|
-
return jsonResponse({ ok: true, provider, mode, selected: [],
|
|
867
|
+
return jsonResponse({ ok: true, provider, mode, selected: [], ...await convergeVisibleCatalogs() });
|
|
856
868
|
}
|
|
857
869
|
if (mode === "custom") {
|
|
858
870
|
// Keep whatever is selected; only the marker changes, so a user can pin their edits
|
|
@@ -897,7 +909,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
897
909
|
mode: "preset",
|
|
898
910
|
appliedVersion: preset.version,
|
|
899
911
|
selected: presetIds,
|
|
900
|
-
|
|
912
|
+
...await convergeVisibleCatalogs(),
|
|
901
913
|
});
|
|
902
914
|
}
|
|
903
915
|
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
|
|
@@ -921,8 +933,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
921
933
|
// re-materialize over it afterwards.
|
|
922
934
|
markModelPresetDiverged(config.providers[provider]);
|
|
923
935
|
persistConfig(config);
|
|
924
|
-
|
|
925
|
-
return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });
|
|
936
|
+
return jsonResponse({ ok: true, provider, selected: models, ...await convergeVisibleCatalogs() });
|
|
926
937
|
}
|
|
927
938
|
return null;
|
|
928
939
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { CatalogModel } from "../../codex/catalog";
|
|
12
12
|
import {
|
|
13
13
|
catalogModelSlug,
|
|
14
|
+
filterCatalogVisibleModels,
|
|
14
15
|
accountBoundNativeOpenAiSlugsBySelector,
|
|
15
16
|
nativeDefaultReasoningEffort,
|
|
16
17
|
NATIVE_OPENAI_MODELS,
|
|
@@ -169,7 +170,11 @@ export async function listManagementModelRows(
|
|
|
169
170
|
...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
|
|
170
171
|
};
|
|
171
172
|
}).filter((row): row is ManagementModelRow => row !== null);
|
|
172
|
-
|
|
173
|
+
// Manual OpenAI rows retain their routed selector but replace the bare dashboard row.
|
|
174
|
+
// Account-qualified rows remain distinct, explicitly selected routes.
|
|
175
|
+
const visibleNative = native.filter(model => model.id.includes("/")
|
|
176
|
+
|| !customNamespaced.has(routedSlug(model.provider, model.id)));
|
|
177
|
+
const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels];
|
|
173
178
|
// Include disabled rows and configured aliases before the export visibility filter:
|
|
174
179
|
// a hidden real `x--fast` must never become a synthetic selector for another model.
|
|
175
180
|
const knownIds = config.fastRows === false ? new Set<string>() : knownEffortRowIds(config);
|
|
@@ -213,5 +218,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
|
|
|
213
218
|
*/
|
|
214
219
|
export async function loadExportModels(config: OcxConfig): Promise<ExportModel[]> {
|
|
215
220
|
const rows = await listManagementModelRows(config);
|
|
216
|
-
|
|
221
|
+
// Management deliberately lists the full roster so hidden models can be enabled.
|
|
222
|
+
// A client picker must also honor the provider selection, not just its blocklist.
|
|
223
|
+
const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config));
|
|
224
|
+
return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel);
|
|
217
225
|
}
|
|
@@ -129,6 +129,11 @@ function validateKeyName(
|
|
|
129
129
|
export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
130
130
|
const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx;
|
|
131
131
|
|
|
132
|
+
if (url.pathname === "/api/accounts/events" && req.method === "GET") {
|
|
133
|
+
const { accountSelectionStream } = await import("./account-selection-stream");
|
|
134
|
+
return accountSelectionStream(req, () => ctx.sessionControl?.isCurrent(req, config) === true);
|
|
135
|
+
}
|
|
136
|
+
|
|
132
137
|
// Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
|
|
133
138
|
if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
|
|
134
139
|
return jsonResponse({ providers: listOAuthProviders() });
|
|
@@ -320,6 +325,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
320
325
|
if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
321
326
|
const { setActiveAccount } = await import("../../oauth/store");
|
|
322
327
|
if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
|
|
328
|
+
const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover");
|
|
329
|
+
forgetGenericFailoverRoster(provider);
|
|
323
330
|
if (provider === "anthropic") {
|
|
324
331
|
const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
|
|
325
332
|
resetAnthropicRoutingForManualSelection(body.accountId);
|