@bitkyc08/opencodex 2.39.0 → 2.40.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 +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { readCodexTokens } from "./auth-collision";
|
|
5
5
|
import {
|
|
@@ -19,6 +19,8 @@ import { resolveCodexHomeDir } from "./home";
|
|
|
19
19
|
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
|
|
20
20
|
import { clearAccountNeedsReauth } from "./account-runtime-state";
|
|
21
21
|
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
22
|
+
import { withNativeMainExclusiveClaim } from "./native-main-claim";
|
|
23
|
+
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
22
24
|
|
|
23
25
|
export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
24
26
|
|
|
@@ -35,6 +37,16 @@ let beforeMainAuthJsonRenameForTests: (() => void) | null = null;
|
|
|
35
37
|
type MainAuthJsonCredential = {
|
|
36
38
|
path: string;
|
|
37
39
|
rawSha256: string;
|
|
40
|
+
/**
|
|
41
|
+
* Filesystem identity of the file the hash was taken from (#2999).
|
|
42
|
+
*
|
|
43
|
+
* A content hash cannot tell "unchanged" from "replaced with a file that happens to
|
|
44
|
+
* hash the same", and more importantly it is read at a different instant than the
|
|
45
|
+
* rename. Carrying dev+ino lets the pre-rename guard ask the sharper question: is this
|
|
46
|
+
* still the same file, not merely one with the same bytes. `null` when the target could
|
|
47
|
+
* not be stat'ed, which is treated as "cannot prove identity" rather than "matches".
|
|
48
|
+
*/
|
|
49
|
+
identity: { dev: number; ino: number } | null;
|
|
38
50
|
root: Record<string, unknown>;
|
|
39
51
|
tokens: Record<string, unknown>;
|
|
40
52
|
accessToken?: string;
|
|
@@ -71,6 +83,22 @@ function sha256(value: string): string {
|
|
|
71
83
|
return createHash("sha256").update(value).digest("hex");
|
|
72
84
|
}
|
|
73
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Filesystem identity of a path, or null when it cannot be read.
|
|
88
|
+
*
|
|
89
|
+
* Null is deliberately NOT "matches anything": a caller that cannot prove identity must
|
|
90
|
+
* fail closed, because the whole point here is refusing to overwrite a file we can no
|
|
91
|
+
* longer vouch for.
|
|
92
|
+
*/
|
|
93
|
+
function statIdentity(path: string): { dev: number; ino: number } | null {
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(path);
|
|
96
|
+
return { dev: Number(stat.dev), ino: Number(stat.ino) };
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
function readMainAuthJsonCredential(): MainAuthJsonCredential | null {
|
|
75
103
|
const path = resolveWriteTarget(join(resolveCodexHomeDir(), "auth.json"));
|
|
76
104
|
let raw: string;
|
|
@@ -96,6 +124,7 @@ function readMainAuthJsonCredential(): MainAuthJsonCredential | null {
|
|
|
96
124
|
return {
|
|
97
125
|
path,
|
|
98
126
|
rawSha256: sha256(raw),
|
|
127
|
+
identity: statIdentity(path),
|
|
99
128
|
root,
|
|
100
129
|
tokens,
|
|
101
130
|
...(accessToken ? { accessToken } : {}),
|
|
@@ -129,6 +158,21 @@ function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential):
|
|
|
129
158
|
if (!current || current.path !== expected.path || current.rawSha256 !== expected.rawSha256) {
|
|
130
159
|
throw new MainAuthJsonChangedDuringRefreshError();
|
|
131
160
|
}
|
|
161
|
+
// Identity, not just content (#2999). A writer can land between this check and the
|
|
162
|
+
// rename, and rename(2) replaces unconditionally - so the narrower the question asked
|
|
163
|
+
// here, the smaller the window where a Codex login gets silently overwritten. An
|
|
164
|
+
// unreadable identity on either side fails closed: unprovable is not the same as equal.
|
|
165
|
+
assertMainAuthJsonIdentityUnchanged(expected);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertMainAuthJsonIdentityUnchanged(expected: MainAuthJsonCredential): void {
|
|
169
|
+
const identity = statIdentity(expected.path);
|
|
170
|
+
if (!identity
|
|
171
|
+
|| !expected.identity
|
|
172
|
+
|| identity.dev !== expected.identity.dev
|
|
173
|
+
|| identity.ino !== expected.identity.ino) {
|
|
174
|
+
throw new MainAuthJsonChangedDuringRefreshError();
|
|
175
|
+
}
|
|
132
176
|
}
|
|
133
177
|
|
|
134
178
|
function persistRefreshedMainAuthJson(
|
|
@@ -158,6 +202,9 @@ function persistRefreshedMainAuthJson(
|
|
|
158
202
|
beforeMainAuthJsonRenameForTests = null;
|
|
159
203
|
hook?.();
|
|
160
204
|
},
|
|
205
|
+
// Runs immediately before rename(2), after the test hook has had its chance to
|
|
206
|
+
// simulate an external writer. Full snapshot check (content AND identity): this is
|
|
207
|
+
// the last look we get, so it asks everything it can rather than the cheap question.
|
|
161
208
|
validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected),
|
|
162
209
|
},
|
|
163
210
|
);
|
|
@@ -187,41 +234,66 @@ async function resolveMainAccountToken(
|
|
|
187
234
|
: null;
|
|
188
235
|
}
|
|
189
236
|
|
|
237
|
+
const refreshTimeout = AbortSignal.timeout(30_000);
|
|
190
238
|
const signal = dependencies.signal
|
|
191
|
-
? AbortSignal.any([dependencies.signal,
|
|
192
|
-
:
|
|
239
|
+
? AbortSignal.any([dependencies.signal, refreshTimeout])
|
|
240
|
+
: refreshTimeout;
|
|
193
241
|
const lockKey = refreshGrantFingerprintForToken(initial.refreshToken);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
242
|
+
// Two locks, because they guard two different things that live in two different
|
|
243
|
+
// homes. `withCodexRefreshFileLock` is keyed on the grant fingerprint and lives
|
|
244
|
+
// under OPENCODEX_HOME; it serializes refreshes of the SAME grant within one
|
|
245
|
+
// install. The file being rewritten is `auth.json` under CODEX_HOME, which every
|
|
246
|
+
// OpenCodex install on the machine shares no matter what its own home is -- so two
|
|
247
|
+
// proxies with distinct OPENCODEX_HOMEs took two unrelated fingerprint locks and
|
|
248
|
+
// refreshed the one credential concurrently (#2999).
|
|
249
|
+
//
|
|
250
|
+
// The outer claim is the CODEX_HOME coordination the other native-main paths
|
|
251
|
+
// already use (`.opencodex-native-main.claim.sqlite`), so this needs no new
|
|
252
|
+
// primitive and no FFI. Order is claim (machine-wide) then fingerprint lock
|
|
253
|
+
// (per-grant), never the reverse: two processes holding different fingerprint
|
|
254
|
+
// locks and then reaching for the same claim would deadlock.
|
|
255
|
+
try {
|
|
256
|
+
return await withNativeMainExclusiveClaim(
|
|
257
|
+
resolveNativeProfileContext(),
|
|
258
|
+
() => withCodexRefreshFileLock(lockKey, signal, async () => {
|
|
259
|
+
const locked = readMainAuthJsonCredential();
|
|
260
|
+
if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
|
|
261
|
+
if (!locked.refreshToken
|
|
262
|
+
|| refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
|
|
263
|
+
if (locked.accessToken !== rejectedAccessToken
|
|
264
|
+
&& mainAccessTokenFresh(locked.accessToken, Date.now(), 0)) {
|
|
265
|
+
return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
|
|
266
|
+
}
|
|
267
|
+
throw new MainAuthJsonChangedDuringRefreshError();
|
|
268
|
+
}
|
|
269
|
+
if (locked.accessToken !== rejectedAccessToken
|
|
270
|
+
&& mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
|
|
271
|
+
return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
|
|
272
|
+
}
|
|
273
|
+
const refresh = dependencies.refreshToken
|
|
274
|
+
?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
|
|
275
|
+
let refreshed: OAuthCredentials;
|
|
276
|
+
try {
|
|
277
|
+
refreshed = await refresh(locked.refreshToken, { signal });
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
const message = cause instanceof Error ? cause.message.toLowerCase() : "";
|
|
280
|
+
const reason = /invalid_grant|invalidated|revoked|expired/.test(message)
|
|
281
|
+
? "reauth" as const
|
|
282
|
+
: "transient" as const;
|
|
283
|
+
throw new MainAccountTokenRefreshError(reason, { cause });
|
|
284
|
+
}
|
|
285
|
+
const result = persistRefreshedMainAuthJson(locked, refreshed);
|
|
286
|
+
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
287
|
+
return result;
|
|
288
|
+
}),
|
|
289
|
+
{ waitMs: 30_000, signal },
|
|
290
|
+
);
|
|
291
|
+
} catch (cause) {
|
|
292
|
+
if (refreshTimeout.aborted && !dependencies.signal?.aborted) {
|
|
293
|
+
throw new MainAccountTokenRefreshError("transient", { cause });
|
|
220
294
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return result;
|
|
224
|
-
});
|
|
295
|
+
throw cause;
|
|
296
|
+
}
|
|
225
297
|
}
|
|
226
298
|
|
|
227
299
|
/** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */
|
|
@@ -17,6 +17,7 @@ export const NATIVE_MAIN_CLAIM_DB = ".opencodex-native-main.claim.sqlite";
|
|
|
17
17
|
export interface NativeMainClaimOptions {
|
|
18
18
|
waitMs?: number;
|
|
19
19
|
pollMs?: number;
|
|
20
|
+
signal?: AbortSignal;
|
|
20
21
|
hardenPath?: (path: string) => Promise<void>;
|
|
21
22
|
platform?: NodeJS.Platform;
|
|
22
23
|
env?: NodeJS.ProcessEnv;
|
|
@@ -121,6 +122,23 @@ function releaseClaim(database: Database | undefined, file: StableLockFile | und
|
|
|
121
122
|
try { file?.close(); } catch { /* operation already completed */ }
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
function waitForClaimRetry(ms: number, signal?: AbortSignal): Promise<void> {
|
|
126
|
+
if (!signal) return Bun.sleep(ms);
|
|
127
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
signal.removeEventListener("abort", onAbort);
|
|
131
|
+
resolve();
|
|
132
|
+
}, ms);
|
|
133
|
+
const onAbort = () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
signal.removeEventListener("abort", onAbort);
|
|
136
|
+
reject(signal.reason);
|
|
137
|
+
};
|
|
138
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
124
142
|
export async function withNativeMainSharedClaim<T>(
|
|
125
143
|
context: NativeProfileContext,
|
|
126
144
|
operation: () => Promise<T>,
|
|
@@ -151,9 +169,11 @@ export async function withNativeMainExclusiveClaim<T>(
|
|
|
151
169
|
operation: () => Promise<T>,
|
|
152
170
|
options: NativeMainClaimOptions = {},
|
|
153
171
|
): Promise<T> {
|
|
172
|
+
const signal = options.signal;
|
|
154
173
|
const deadline = Date.now() + Math.max(0, options.waitMs ?? 0);
|
|
155
174
|
const pollMs = Math.max(1, options.pollMs ?? 50);
|
|
156
175
|
for (;;) {
|
|
176
|
+
if (signal?.aborted) throw signal.reason;
|
|
157
177
|
let database: Database | undefined;
|
|
158
178
|
let file: StableLockFile | undefined;
|
|
159
179
|
try {
|
|
@@ -162,14 +182,16 @@ export async function withNativeMainExclusiveClaim<T>(
|
|
|
162
182
|
assertStableLockFile(nativeMainClaimPath(context), file);
|
|
163
183
|
} catch (error) {
|
|
164
184
|
releaseClaim(database, file);
|
|
185
|
+
if (signal?.aborted) throw signal.reason;
|
|
165
186
|
const mapped = mapClaimSetupError(error, "Native-main credentials are in use.");
|
|
166
187
|
if (mapped.code === "NATIVE_MAIN_CLAIM_BUSY" && Date.now() < deadline) {
|
|
167
|
-
await
|
|
188
|
+
await waitForClaimRetry(Math.min(pollMs, Math.max(1, deadline - Date.now())), signal);
|
|
168
189
|
continue;
|
|
169
190
|
}
|
|
170
191
|
throw mapped;
|
|
171
192
|
}
|
|
172
193
|
try {
|
|
194
|
+
if (signal?.aborted) throw signal.reason;
|
|
173
195
|
return await operation();
|
|
174
196
|
} finally {
|
|
175
197
|
releaseClaim(database, file);
|
|
@@ -133,6 +133,13 @@ function resolveRegularFile(path: string): PathResult {
|
|
|
133
133
|
function readRegularFile(path: string): ReadResult {
|
|
134
134
|
const resolved = resolveRegularFile(path);
|
|
135
135
|
if (resolved.kind !== "path") return resolved;
|
|
136
|
+
// Root can read a chmod(000) file on Linux, which made the residue verdict
|
|
137
|
+
// depend on who ran the suite. No read bit means the configured surface is
|
|
138
|
+
// operationally unreadable to an ordinary Codex process and must remain
|
|
139
|
+
// indeterminate even when the inspector itself has elevated privileges.
|
|
140
|
+
if (process.platform !== "win32" && (resolved.stat.mode & 0o444) === 0) {
|
|
141
|
+
return { kind: "indeterminate", reason: "EACCES: surface has no read permission bits" };
|
|
142
|
+
}
|
|
136
143
|
try {
|
|
137
144
|
const content = readFileSync(resolved.path, "utf8");
|
|
138
145
|
const after = statSync(resolved.path);
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { atomicWriteFile } from "../config/atomic-write";
|
|
5
|
+
import { getConfigDir } from "../config/paths";
|
|
6
|
+
import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks";
|
|
7
|
+
import type { OcxConfig } from "../types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Opt-in auto-redemption of a Codex reset credit shortly before it expires (#822).
|
|
11
|
+
*
|
|
12
|
+
* Default off. When enabled, the nearest unexpired credit for the main Codex account is
|
|
13
|
+
* redeemed `leadTimeMinutes` before its `expires_at`. Every fire re-reads the upstream credit
|
|
14
|
+
* list first and dispatches only when the same credit (granted_at + expires_at) is still
|
|
15
|
+
* present, so a credit the operator already spent by hand is never redeemed twice. The
|
|
16
|
+
* `redeem_request_id` for a credit identity is minted once and journaled to disk before the
|
|
17
|
+
* consume call, so a crash between dispatch and settle replays the same idempotent request
|
|
18
|
+
* instead of spending a second credit. Logs carry a hashed account key only.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ResetCreditAutoRedeemSettings {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
leadTimeMinutes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_LEAD_TIME_MINUTES = 10;
|
|
27
|
+
export const MIN_LEAD_TIME_MINUTES = 1;
|
|
28
|
+
export const MAX_LEAD_TIME_MINUTES = 60;
|
|
29
|
+
|
|
30
|
+
export function resolveResetCreditAutoRedeemSettings(config: Pick<OcxConfig, "resetCreditAutoRedeem">): ResetCreditAutoRedeemSettings {
|
|
31
|
+
const raw = config.resetCreditAutoRedeem;
|
|
32
|
+
if (!raw || raw.enabled !== true) return { enabled: false, leadTimeMinutes: DEFAULT_LEAD_TIME_MINUTES };
|
|
33
|
+
const lead = typeof raw.leadTimeMinutes === "number" && Number.isInteger(raw.leadTimeMinutes)
|
|
34
|
+
? Math.min(Math.max(raw.leadTimeMinutes, MIN_LEAD_TIME_MINUTES), MAX_LEAD_TIME_MINUTES)
|
|
35
|
+
: DEFAULT_LEAD_TIME_MINUTES;
|
|
36
|
+
return { enabled: true, leadTimeMinutes: lead };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResetCredit {
|
|
40
|
+
granted_at: string;
|
|
41
|
+
expires_at: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AutoRedeemPlan {
|
|
45
|
+
/** Stable identity of the credit being protected. */
|
|
46
|
+
grantedAt: string;
|
|
47
|
+
expiresAt: string;
|
|
48
|
+
/** Epoch ms at which the redeem should be attempted. */
|
|
49
|
+
dueAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pick the credit that expires soonest and is still in the future; null when nothing qualifies. */
|
|
53
|
+
export function planAutoRedeem(now: number, credits: readonly ResetCredit[], settings: ResetCreditAutoRedeemSettings): AutoRedeemPlan | null {
|
|
54
|
+
if (!settings.enabled) return null;
|
|
55
|
+
let best: AutoRedeemPlan | null = null;
|
|
56
|
+
for (const credit of credits) {
|
|
57
|
+
const expires = Date.parse(credit.expires_at);
|
|
58
|
+
if (!Number.isFinite(expires) || expires <= now) continue;
|
|
59
|
+
const dueAt = expires - settings.leadTimeMinutes * 60_000;
|
|
60
|
+
if (!best || expires < Date.parse(best.expiresAt)) best = { grantedAt: credit.granted_at, expiresAt: credit.expires_at, dueAt };
|
|
61
|
+
}
|
|
62
|
+
return best;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function creditStillPresent(credits: readonly ResetCredit[], plan: Pick<AutoRedeemPlan, "grantedAt" | "expiresAt">): boolean {
|
|
66
|
+
return credits.some(c => c.granted_at === plan.grantedAt && c.expires_at === plan.expiresAt);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface JournalEntry {
|
|
70
|
+
accountKey: string;
|
|
71
|
+
grantedAt: string;
|
|
72
|
+
expiresAt: string;
|
|
73
|
+
redeemRequestId: string;
|
|
74
|
+
state: "dispatched" | "settled";
|
|
75
|
+
updatedAt: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Journal { version: 1; entries: JournalEntry[] }
|
|
79
|
+
|
|
80
|
+
export function journalPath(): string {
|
|
81
|
+
return join(getConfigDir(), "reset-credit-auto-redeem.json");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readJournal(path: string): Journal {
|
|
85
|
+
if (!existsSync(path)) return { version: 1, entries: [] };
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Journal;
|
|
88
|
+
return parsed && parsed.version === 1 && Array.isArray(parsed.entries) ? parsed : { version: 1, entries: [] };
|
|
89
|
+
} catch {
|
|
90
|
+
return { version: 1, entries: [] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeJournal(path: string, journal: Journal): void {
|
|
95
|
+
// Keep only entries whose credit could still matter: settled ones older than a week are noise.
|
|
96
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60_000;
|
|
97
|
+
journal.entries = journal.entries.filter(e => e.state !== "settled" || e.updatedAt > cutoff);
|
|
98
|
+
atomicWriteFile(path, JSON.stringify(journal, null, 2));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function hashAccountKey(accountId: string): string {
|
|
102
|
+
return createHash("sha256").update(accountId).digest("hex").slice(0, 12);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AutoRedeemDeps {
|
|
106
|
+
accountId: string;
|
|
107
|
+
settings: () => ResetCreditAutoRedeemSettings;
|
|
108
|
+
/** Fresh upstream read of the credit list; throws on auth/transport failure. */
|
|
109
|
+
inspect: () => Promise<{ credits: ResetCredit[] }>;
|
|
110
|
+
/** Consume with a caller-owned idempotency key. Returns the upstream code. */
|
|
111
|
+
consume: (redeemRequestId: string) => Promise<{ code: string }>;
|
|
112
|
+
now?: () => number;
|
|
113
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
114
|
+
clearTimer?: (handle: unknown) => void;
|
|
115
|
+
journalFile?: string;
|
|
116
|
+
log?: (line: string) => void;
|
|
117
|
+
/** Upper bound on one sleep so a laptop sleep or clock jump re-checks rather than trusting a stale plan. */
|
|
118
|
+
maxSleepMs?: number;
|
|
119
|
+
/** Interval to re-inspect when no credit is due yet (default 30 min). */
|
|
120
|
+
idleRecheckMs?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type AutoRedeemOutcome =
|
|
124
|
+
| { kind: "disabled" }
|
|
125
|
+
| { kind: "nothing-to-protect" }
|
|
126
|
+
| { kind: "scheduled"; dueAt: number }
|
|
127
|
+
| { kind: "skipped"; reason: "credit-gone" | "disabled-before-dispatch" }
|
|
128
|
+
| { kind: "dispatched"; code: string; redeemRequestId: string }
|
|
129
|
+
| { kind: "ambiguous"; redeemRequestId: string }
|
|
130
|
+
| { kind: "error"; message: string };
|
|
131
|
+
|
|
132
|
+
export interface ResetCreditAutoRedeemer {
|
|
133
|
+
/** Inspect, and either dispatch (if due) or schedule the next check. */
|
|
134
|
+
tick(): Promise<AutoRedeemOutcome>;
|
|
135
|
+
start(): void;
|
|
136
|
+
stop(): void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCreditAutoRedeemer {
|
|
140
|
+
const now = deps.now ?? (() => Date.now());
|
|
141
|
+
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
142
|
+
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
143
|
+
const log = deps.log ?? ((line: string) => console.log(line));
|
|
144
|
+
const path = deps.journalFile ?? journalPath();
|
|
145
|
+
const accountKey = hashAccountKey(deps.accountId);
|
|
146
|
+
const maxSleepMs = deps.maxSleepMs ?? 15 * 60_000;
|
|
147
|
+
const idleRecheckMs = deps.idleRecheckMs ?? 30 * 60_000;
|
|
148
|
+
let handle: unknown = null;
|
|
149
|
+
let stopped = false;
|
|
150
|
+
let inFlight: Promise<AutoRedeemOutcome> | null = null;
|
|
151
|
+
|
|
152
|
+
const schedule = (ms: number): void => {
|
|
153
|
+
if (stopped) return;
|
|
154
|
+
if (handle !== null) clearTimer(handle);
|
|
155
|
+
handle = setTimer(() => { handle = null; void tick(); }, Math.max(0, Math.min(ms, maxSleepMs)));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const dispatch = async (plan: AutoRedeemPlan): Promise<AutoRedeemOutcome> => {
|
|
159
|
+
const journal = readJournal(path);
|
|
160
|
+
let entry = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt);
|
|
161
|
+
if (entry?.state === "settled") return { kind: "skipped", reason: "credit-gone" };
|
|
162
|
+
if (!entry) {
|
|
163
|
+
entry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() };
|
|
164
|
+
journal.entries.push(entry);
|
|
165
|
+
// Journal BEFORE the network call: a crash after this line replays the same request id.
|
|
166
|
+
writeJournal(path, journal);
|
|
167
|
+
}
|
|
168
|
+
log(`[opencodex] reset-credit auto-redeem: dispatching for account ${accountKey} (credit expires ${plan.expiresAt})`);
|
|
169
|
+
let result: { code: string };
|
|
170
|
+
try {
|
|
171
|
+
result = await deps.consume(entry.redeemRequestId);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
log(`[opencodex] reset-credit auto-redeem: consume uncertain for account ${accountKey}; will retry with the same request id`);
|
|
174
|
+
schedule(60_000);
|
|
175
|
+
return { kind: "ambiguous", redeemRequestId: entry.redeemRequestId };
|
|
176
|
+
}
|
|
177
|
+
entry.state = "settled";
|
|
178
|
+
entry.updatedAt = now();
|
|
179
|
+
writeJournal(path, journal);
|
|
180
|
+
log(`[opencodex] reset-credit auto-redeem: upstream answered ${result.code} for account ${accountKey}`);
|
|
181
|
+
schedule(idleRecheckMs);
|
|
182
|
+
return { kind: "dispatched", code: result.code, redeemRequestId: entry.redeemRequestId };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const tick = async (): Promise<AutoRedeemOutcome> => {
|
|
186
|
+
if (inFlight) return inFlight;
|
|
187
|
+
inFlight = (async () => {
|
|
188
|
+
const settings = deps.settings();
|
|
189
|
+
if (!settings.enabled) return { kind: "disabled" } as AutoRedeemOutcome;
|
|
190
|
+
let credits: ResetCredit[];
|
|
191
|
+
try {
|
|
192
|
+
({ credits } = await deps.inspect());
|
|
193
|
+
} catch (error) {
|
|
194
|
+
schedule(idleRecheckMs);
|
|
195
|
+
return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome;
|
|
196
|
+
}
|
|
197
|
+
const plan = planAutoRedeem(now(), credits, settings);
|
|
198
|
+
if (!plan) { schedule(idleRecheckMs); return { kind: "nothing-to-protect" } as AutoRedeemOutcome; }
|
|
199
|
+
if (plan.dueAt > now()) { schedule(plan.dueAt - now()); return { kind: "scheduled", dueAt: plan.dueAt } as AutoRedeemOutcome; }
|
|
200
|
+
// Due: re-read right before spending. The plan above came from this same inspect, but
|
|
201
|
+
// the settings may have flipped and a manual consume may have raced; check both again.
|
|
202
|
+
if (!deps.settings().enabled) return { kind: "skipped", reason: "disabled-before-dispatch" } as AutoRedeemOutcome;
|
|
203
|
+
let fresh: ResetCredit[];
|
|
204
|
+
try { ({ credits: fresh } = await deps.inspect()); } catch (error) {
|
|
205
|
+
schedule(60_000);
|
|
206
|
+
return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome;
|
|
207
|
+
}
|
|
208
|
+
if (!creditStillPresent(fresh, plan)) { schedule(idleRecheckMs); return { kind: "skipped", reason: "credit-gone" } as AutoRedeemOutcome; }
|
|
209
|
+
return dispatch(plan);
|
|
210
|
+
})().finally(() => { inFlight = null; });
|
|
211
|
+
return inFlight;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
tick,
|
|
216
|
+
start() { stopped = false; void tick(); },
|
|
217
|
+
stop() { stopped = true; if (handle !== null) { clearTimer(handle); handle = null; } },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Composition-root activation. Returns the redeemer only when the opt-in is on; the caller
|
|
223
|
+
* (src/server/index.ts) must not await this and must gate on `enabled` itself so a default
|
|
224
|
+
* install never constructs the timer.
|
|
225
|
+
*/
|
|
226
|
+
export function activateResetCreditAutoRedeem(
|
|
227
|
+
config: OcxConfig,
|
|
228
|
+
wham: Pick<AutoRedeemDeps, "inspect" | "consume" | "accountId">,
|
|
229
|
+
): ResetCreditAutoRedeemer {
|
|
230
|
+
const redeemer = createResetCreditAutoRedeemer({
|
|
231
|
+
...wham,
|
|
232
|
+
settings: () => resolveResetCreditAutoRedeemSettings(config),
|
|
233
|
+
});
|
|
234
|
+
const unregister = registerOptionalShutdownHook("reset-credit-auto-redeem", () => { redeemer.stop(); unregister(); });
|
|
235
|
+
redeemer.start();
|
|
236
|
+
return redeemer;
|
|
237
|
+
}
|
package/src/combos/request.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types";
|
|
2
|
+
import { resolveEffortAtOrBelow } from "../reasoning-effort";
|
|
2
3
|
import { resolveComboId } from "./types";
|
|
3
4
|
|
|
4
5
|
const warnedUnsupportedDefaults = new Set<string>();
|
|
@@ -72,7 +73,18 @@ export function concreteComboRequestBody(
|
|
|
72
73
|
if (!needsDefault) return clone;
|
|
73
74
|
// Picker availability treats an unknown ladder as a wildcard, but runtime
|
|
74
75
|
// injection stays fail-closed until this concrete target advertises support.
|
|
75
|
-
|
|
76
|
+
//
|
|
77
|
+
// Support is not literal membership. The catalog advertises the combo's default
|
|
78
|
+
// through effectiveComboDefault, which keeps the highest supported rung at or
|
|
79
|
+
// below the request rather than dropping it. Testing membership here meant a
|
|
80
|
+
// combo configured for `max` against a target topping out at `high` sent no
|
|
81
|
+
// effort at all, so the provider default applied and the turn ran at `none`
|
|
82
|
+
// while the catalog still advertised `max` (#3108). Resolve the same way the
|
|
83
|
+
// catalog did.
|
|
84
|
+
const resolvedEffort = targetReasoningEfforts === undefined
|
|
85
|
+
? undefined
|
|
86
|
+
: resolveEffortAtOrBelow(defaultEffort, targetReasoningEfforts);
|
|
87
|
+
if (!resolvedEffort) {
|
|
76
88
|
const key = `${target.provider}/${target.model}:${defaultEffort}`;
|
|
77
89
|
if (!warnedUnsupportedDefaults.has(key)) {
|
|
78
90
|
warnedUnsupportedDefaults.add(key);
|
|
@@ -86,9 +98,9 @@ export function concreteComboRequestBody(
|
|
|
86
98
|
return clone;
|
|
87
99
|
}
|
|
88
100
|
if (reasoning === undefined) {
|
|
89
|
-
clone.reasoning = { effort:
|
|
101
|
+
clone.reasoning = { effort: resolvedEffort };
|
|
90
102
|
} else {
|
|
91
|
-
clone.reasoning = { ...(reasoning as Record<string, unknown>), effort:
|
|
103
|
+
clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: resolvedEffort };
|
|
92
104
|
}
|
|
93
105
|
return clone;
|
|
94
106
|
}
|
package/src/combos/types.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
|
|
|
3
3
|
import type {
|
|
4
4
|
OcxComboConfig,
|
|
5
5
|
OcxComboDefaultEffort,
|
|
6
|
+
OcxComboReasoningEffortMode,
|
|
6
7
|
OcxComboStrategy,
|
|
7
8
|
OcxComboTarget,
|
|
8
9
|
OcxConfig,
|
|
@@ -37,6 +38,8 @@ export interface NormalizedComboConfig {
|
|
|
37
38
|
strategy: OcxComboStrategy;
|
|
38
39
|
stickyLimit: number;
|
|
39
40
|
defaultEffort: OcxComboDefaultEffort | null;
|
|
41
|
+
/** Picker-ladder derivation policy; `strict` preserves the legacy intersection rule. */
|
|
42
|
+
reasoningEffortMode: OcxComboReasoningEffortMode;
|
|
40
43
|
/** Disable image input; `auto` preserves the intersection derived from all targets. */
|
|
41
44
|
imageInput: "auto" | "disabled";
|
|
42
45
|
/** Trimmed public alias, or null when the combo keeps the default `combo/<id>` slug. */
|
|
@@ -238,6 +241,14 @@ export function comboConfigIssues(
|
|
|
238
241
|
if (body.imageInput !== undefined && body.imageInput !== "auto" && body.imageInput !== "disabled") {
|
|
239
242
|
issues.push({ path: ["imageInput"], message: 'imageInput must be "auto" or "disabled"' });
|
|
240
243
|
}
|
|
244
|
+
if (body.reasoningEffortMode !== undefined
|
|
245
|
+
&& body.reasoningEffortMode !== "strict"
|
|
246
|
+
&& body.reasoningEffortMode !== "adaptive") {
|
|
247
|
+
issues.push({
|
|
248
|
+
path: ["reasoningEffortMode"],
|
|
249
|
+
message: 'reasoningEffortMode must be "strict" or "adaptive"',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
241
252
|
|
|
242
253
|
if (body.alias !== undefined) {
|
|
243
254
|
if (typeof body.alias !== "string") {
|
|
@@ -357,6 +368,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
|
|
|
357
368
|
strategy: raw.strategy ?? "failover",
|
|
358
369
|
stickyLimit: raw.stickyLimit ?? 1,
|
|
359
370
|
defaultEffort: raw.defaultEffort ?? null,
|
|
371
|
+
reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict",
|
|
360
372
|
imageInput: raw.imageInput === "disabled" ? "disabled" : "auto",
|
|
361
373
|
alias: alias || null,
|
|
362
374
|
nativeAlias: raw.nativeAlias === true,
|
package/src/config/paths.ts
CHANGED
|
@@ -49,7 +49,21 @@ export function hardenConfigDir(): void {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Settle the optional hardening flight for one config directory.
|
|
54
|
+
*
|
|
55
|
+
* The flight spawns `icacls.exe`, which holds the directory open until it exits. Windows file
|
|
56
|
+
* locking is mandatory, so anything that removes or renames that directory after a "clean"
|
|
57
|
+
* shutdown — a test fixture teardown, an uninstaller, a home move — gets EPERM/EBUSY unless the
|
|
58
|
+
* process that started the child also waits for it. `server.stop` calls this so the shutdown
|
|
59
|
+
* contract owns every child it started. No-op when nothing is in flight.
|
|
60
|
+
*/
|
|
61
|
+
export async function flushConfigDirHardening(dir: string = getConfigDir()): Promise<void> {
|
|
62
|
+
const flight = configDirHardeningFlights.get(dir);
|
|
63
|
+
if (flight) await flight;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Test-only: settle every in-flight config-directory harden regardless of directory. */
|
|
53
67
|
export async function flushConfigDirHardeningForTests(): Promise<void> {
|
|
54
68
|
await Promise.all([...configDirHardeningFlights.values()]);
|
|
55
69
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
2
2
|
import { redactSecretString } from "../lib/redact";
|
|
3
|
+
import {
|
|
4
|
+
isValidModelDiscoveryModelId,
|
|
5
|
+
MODEL_DISCOVERY_MAX_MODELS,
|
|
6
|
+
} from "../providers/model-discovery-limits";
|
|
3
7
|
import { modelRecordValue } from "../reasoning-effort";
|
|
4
8
|
import {
|
|
5
9
|
isWirePinnedModel,
|
|
@@ -20,6 +24,8 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([
|
|
|
20
24
|
"x-amz-security-token",
|
|
21
25
|
]);
|
|
22
26
|
const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
|
|
27
|
+
const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
28
|
+
const MAX_MODEL_DISPLAY_NAME_LENGTH = 128;
|
|
23
29
|
|
|
24
30
|
/** Validate a provider destination without coupling DTO callers to config persistence. */
|
|
25
31
|
export function providerBaseUrlConfigError(baseUrl: string): string | null {
|
|
@@ -124,6 +130,40 @@ export function booleanRecordConfigError(value: unknown, field: string): string
|
|
|
124
130
|
return null;
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
/** Validate display-only labels without changing the provider's model identity. */
|
|
134
|
+
export function modelDisplayNamesConfigError(
|
|
135
|
+
value: unknown,
|
|
136
|
+
field = "modelDisplayNames",
|
|
137
|
+
): string | null {
|
|
138
|
+
if (value === undefined) return null;
|
|
139
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
140
|
+
const prototype = Object.getPrototypeOf(value);
|
|
141
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
142
|
+
return `${field} must be a plain object with own properties`;
|
|
143
|
+
}
|
|
144
|
+
const entries = Object.entries(value);
|
|
145
|
+
// One discovered model can own one label, so both maps share the same safe cap.
|
|
146
|
+
if (entries.length > MODEL_DISCOVERY_MAX_MODELS) {
|
|
147
|
+
return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`;
|
|
148
|
+
}
|
|
149
|
+
for (const [modelId, displayName] of entries) {
|
|
150
|
+
if (!isValidModelDiscoveryModelId(modelId)) return `${field} keys must be valid model ids`;
|
|
151
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
152
|
+
if (typeof displayName !== "string") return `${field}.${safeModelId} must be a string`;
|
|
153
|
+
const trimmed = displayName.trim();
|
|
154
|
+
if (!trimmed) return `${field}.${safeModelId} must be nonblank`;
|
|
155
|
+
if (displayName !== trimmed) return `${field}.${safeModelId} must be trimmed`;
|
|
156
|
+
if (displayName.length > MAX_MODEL_DISPLAY_NAME_LENGTH) {
|
|
157
|
+
return `${field}.${safeModelId} must be at most ${MAX_MODEL_DISPLAY_NAME_LENGTH} characters`;
|
|
158
|
+
}
|
|
159
|
+
if (displayName.includes("/")) return `${field}.${safeModelId} must not contain /`;
|
|
160
|
+
if (DISPLAY_NAME_CONTROL_CHARS.test(displayName)) {
|
|
161
|
+
return `${field}.${safeModelId} must not contain control characters`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
127
167
|
/** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
|
|
128
168
|
export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
|
|
129
169
|
const raw = provider as Record<string, unknown> | null | undefined;
|