@omercnet/paseo-omp 0.3.0 → 0.4.0-next.114.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +19 -5
  2. package/client/omp-config-surface.tsx +243 -24
  3. package/client/omp-config-views.ts +24 -0
  4. package/client/omp-model-picker-state.ts +145 -0
  5. package/client/omp-model-picker.tsx +282 -0
  6. package/client/omp-routing-editor.tsx +307 -0
  7. package/client/support-diagnostics-state.ts +45 -0
  8. package/index.server.ts +27 -2
  9. package/package.json +2 -8
  10. package/paseo-plugin.json +1 -1
  11. package/server/omp-models.ts +59 -0
  12. package/server/omp-settings.ts +30 -20
  13. package/server/operational-failure-diagnostics.ts +76 -0
  14. package/server/package-version.ts +2 -0
  15. package/server/protocol-violation-diagnostics.ts +169 -0
  16. package/server/provider/catalog.ts +39 -10
  17. package/server/provider/connection.ts +60 -8
  18. package/server/provider/host-tools.ts +284 -34
  19. package/server/provider/mcp-transport.ts +2 -1
  20. package/server/provider/omp-rpc.ts +1011 -107
  21. package/server/provider/profile-providers.ts +7 -2
  22. package/server/provider/registration.ts +12 -2
  23. package/server/provider/security.ts +8 -10
  24. package/server/provider/session-descriptors.ts +45 -11
  25. package/server/provider/session.ts +200 -58
  26. package/server/provider/subsessions.ts +311 -73
  27. package/server/provider/timeline-projector.ts +34 -11
  28. package/server/support-diagnostics.ts +284 -0
  29. package/shared/omp-models.ts +49 -0
  30. package/shared/omp-settings.ts +227 -3
  31. package/shared/support-diagnostics.ts +32 -0
  32. package/CHANGELOG.md +0 -113
  33. package/SUPPORT.md +0 -44
  34. package/TESTING.md +0 -150
  35. package/docs/alpha-release-checklist.md +0 -68
  36. package/docs/configuration.md +0 -126
  37. package/docs/core-provider-issue-audit.md +0 -109
  38. package/docs/images/mcp-authorization-compact.png +0 -0
  39. package/docs/images/mcp-controls-wide.png +0 -0
  40. package/docs/images/plugin-manager.png +0 -0
  41. package/docs/images/workspace-settings.png +0 -0
  42. package/docs/installation.md +0 -89
  43. package/tsconfig.json +0 -16
package/index.server.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "./server/mcp-browser";
7
7
  import { resolveListOmpMemory } from "./server/memory";
8
8
  import { resolveListOmpConfig } from "./server/omp-config";
9
+ import { resolveListOmpModels } from "./server/omp-models";
9
10
  import {
10
11
  resolveInspectOmpPluginConfig,
11
12
  resolveListOmpPlugins,
@@ -13,7 +14,9 @@ import {
13
14
  resolveMutateOmpPluginConfig,
14
15
  } from "./server/omp-plugins";
15
16
  import { resolveListOmpSettings, resolveUpdateOmpSettings } from "./server/omp-settings";
17
+ import { OmpOperationalFailureCollector } from "./server/operational-failure-diagnostics";
16
18
  import { withOmpStore } from "./server/paths";
19
+ import { OmpProtocolViolationCollector } from "./server/protocol-violation-diagnostics";
17
20
  import { withOmpWorkspaceIdentity } from "./server/provider/host-tools";
18
21
  import {
19
22
  createProfileOmpProvider,
@@ -24,11 +27,13 @@ import { createOmpProvider } from "./server/provider/registration";
24
27
  import { resolveGetOmpProviderHealth } from "./server/provider-diagnostics";
25
28
  import { resolveListOmpQuotas } from "./server/quota";
26
29
  import { resolveListOmpSessions } from "./server/sessions";
30
+ import { resolveGetOmpSupportReport } from "./server/support-diagnostics";
27
31
  import { composerPillSettings } from "./shared/composer-pill-settings";
28
32
  import { listHubProcesses, tailHubLog } from "./shared/hub";
29
33
  import { openOmpMcpAuthorizationInPaseoBrowser } from "./shared/mcp";
30
34
  import { listOmpMemory } from "./shared/memory";
31
35
  import { listOmpConfig } from "./shared/omp-config";
36
+ import { listOmpModels } from "./shared/omp-models";
32
37
  import {
33
38
  inspectOmpPluginConfig,
34
39
  listOmpPlugins,
@@ -40,6 +45,7 @@ import { listOmpStores, type OmpStore } from "./shared/omp-store";
40
45
  import { getOmpProviderHealth } from "./shared/provider-diagnostics";
41
46
  import { listOmpQuotas } from "./shared/quota";
42
47
  import { listOmpSessions } from "./shared/sessions";
48
+ import { getOmpSupportReport } from "./shared/support-diagnostics";
43
49
 
44
50
  function scoped<T extends { store?: OmpStore }, R>(handler: (input: T) => R) {
45
51
  return (input: T): R => withOmpStore(input.store, () => handler(input));
@@ -48,10 +54,18 @@ function scoped<T extends { store?: OmpStore }, R>(handler: (input: T) => R) {
48
54
  export default function contribute(server: PluginServerContext) {
49
55
  server.registerSettings(composerPillSettings);
50
56
  const browserAuthorizationRegistry = new OmpBrowserAuthorizationRegistry();
57
+ const protocolViolations = new OmpProtocolViolationCollector();
58
+ const operationalFailures = new OmpOperationalFailureCollector();
51
59
  const profiles = discoverOmpProfilesSync();
52
60
  server.handle(listOmpStores, async () => ({ profiles: await discoverOmpProfiles() }));
53
61
  for (const profile of profiles) {
54
- server.registerProvider(createProfileOmpProvider(profile, { browserAuthorizationRegistry }));
62
+ server.registerProvider(
63
+ createProfileOmpProvider(profile, {
64
+ browserAuthorizationRegistry,
65
+ reportProtocolViolation: protocolViolations.report,
66
+ reportOperationalFailure: operationalFailures.report,
67
+ }),
68
+ );
55
69
  }
56
70
  server.handle(listHubProcesses, resolveListHubProcesses);
57
71
  server.handle(tailHubLog, resolveTailHubLog);
@@ -59,6 +73,7 @@ export default function contribute(server: PluginServerContext) {
59
73
  server.handle(listOmpMemory, scoped(resolveListOmpMemory));
60
74
  server.handle(listOmpSessions, scoped(resolveListOmpSessions));
61
75
  server.handle(listOmpConfig, scoped(resolveListOmpConfig));
76
+ server.handle(listOmpModels, scoped(resolveListOmpModels));
62
77
  server.handle(listOmpPlugins, scoped(resolveListOmpPlugins));
63
78
  server.handle(inspectOmpPluginConfig, scoped(resolveInspectOmpPluginConfig));
64
79
  server.handle(mutateOmpPlugin, scoped(resolveMutateOmpPlugin));
@@ -66,6 +81,10 @@ export default function contribute(server: PluginServerContext) {
66
81
  server.handle(listOmpSettings, scoped(resolveListOmpSettings));
67
82
  server.handle(updateOmpSettings, scoped(resolveUpdateOmpSettings));
68
83
  server.handle(getOmpProviderHealth, scoped(resolveGetOmpProviderHealth));
84
+ server.handle(
85
+ getOmpSupportReport,
86
+ scoped((input) => resolveGetOmpSupportReport(input, protocolViolations, operationalFailures)),
87
+ );
69
88
  server.handle(openOmpMcpAuthorizationInPaseoBrowser, (input) =>
70
89
  resolveOpenOmpMcpAuthorizationInPaseoBrowser(input, browserAuthorizationRegistry),
71
90
  );
@@ -73,7 +92,13 @@ export default function contribute(server: PluginServerContext) {
73
92
  if (request.provider !== "omp-plugin" && !request.provider.startsWith("omp-plugin-")) return;
74
93
  return withOmpWorkspaceIdentity(request);
75
94
  });
76
- server.registerProvider(createOmpProvider({ browserAuthorizationRegistry }));
95
+ server.registerProvider(
96
+ createOmpProvider({
97
+ browserAuthorizationRegistry,
98
+ reportProtocolViolation: protocolViolations.report,
99
+ reportOperationalFailure: operationalFailures.report,
100
+ }),
101
+ );
77
102
  return () => {
78
103
  browserAuthorizationRegistry.clear();
79
104
  removeIdentityHook();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-next.114.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -29,21 +29,15 @@
29
29
  "coding-agents"
30
30
  ],
31
31
  "files": [
32
- "package-lock.json",
33
- "CHANGELOG.md",
34
32
  "LICENSE",
35
33
  "README.md",
36
- "SUPPORT.md",
37
- "TESTING.md",
38
- "docs",
39
34
  "scripts/prepare-dependencies.mjs",
40
35
  "index.client.tsx",
41
36
  "index.server.ts",
42
37
  "client",
43
38
  "server",
44
39
  "shared",
45
- "paseo-plugin.json",
46
- "tsconfig.json"
40
+ "paseo-plugin.json"
47
41
  ],
48
42
  "scripts": {
49
43
  "check": "biome check .",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "id": "paseo-omp",
3
- "requirements": { "paseo": ">=0.8.0 <0.10.0" },
3
+ "requirements": { "paseo": ">=0.9.0-beta.1 <0.10.0" },
4
4
  "build": [["node", "scripts/prepare-dependencies.mjs"]]
5
5
  }
@@ -0,0 +1,59 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute } from "node:path";
3
+ import type { RpcInput } from "@getpaseo/plugin";
4
+ import type { listOmpModels, OmpModelCandidate, OmpModelListResult } from "../shared/omp-models";
5
+ import { currentOmpEnvironment } from "./paths";
6
+ import {
7
+ type OmpModel,
8
+ OmpRpcRuntime,
9
+ type OmpRuntime,
10
+ type OmpRuntimeSession,
11
+ } from "./provider/omp-rpc";
12
+ import { OmpCleanupFailure, OmpPublicDataSerializer, OmpPublicError } from "./provider/security";
13
+
14
+ function mapOmpModel(model: OmpModel, serializer: OmpPublicDataSerializer): OmpModelCandidate {
15
+ const provider = serializer.text(model.provider, 256);
16
+ const id = serializer.text(model.id, 256);
17
+ return {
18
+ selector: `${provider}/${id}`,
19
+ provider,
20
+ id,
21
+ ...(model.name !== undefined ? { name: serializer.text(model.name, 256) } : {}),
22
+ reasoning: model.reasoning === true,
23
+ input: (model.input ?? []).map((value) => serializer.text(value, 256)),
24
+ ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
25
+ thinkingLevels: (model.thinking?.efforts ?? []).map((value) => serializer.text(value, 32)),
26
+ };
27
+ }
28
+
29
+ async function closeModelCatalogSession(session: OmpRuntimeSession): Promise<void> {
30
+ const cleanup = session.close();
31
+ try {
32
+ await cleanup;
33
+ } catch {
34
+ throw new OmpCleanupFailure("OMP model catalog cleanup failed", cleanup);
35
+ }
36
+ }
37
+
38
+ export async function resolveListOmpModels(
39
+ input: RpcInput<typeof listOmpModels>,
40
+ runtime: OmpRuntime = new OmpRpcRuntime({ environment: currentOmpEnvironment() }),
41
+ ): Promise<OmpModelListResult> {
42
+ const cwd = input.cwd ?? homedir();
43
+ if (!isAbsolute(cwd) || cwd.includes("\0")) {
44
+ throw new OmpPublicError("The workspace path is invalid.");
45
+ }
46
+ const session = await runtime.startSession({
47
+ cwd,
48
+ environment: currentOmpEnvironment(),
49
+ noSession: true,
50
+ });
51
+ try {
52
+ const serializer = new OmpPublicDataSerializer();
53
+ return {
54
+ models: (await session.getAvailableModels()).map((model) => mapOmpModel(model, serializer)),
55
+ };
56
+ } finally {
57
+ await closeModelCatalogSession(session);
58
+ }
59
+ }
@@ -5,12 +5,13 @@ import { delimiter, dirname, isAbsolute, join } from "node:path";
5
5
  import type { RpcInput } from "@getpaseo/plugin";
6
6
  import { parseDocument, parse as parseYaml } from "yaml";
7
7
  import {
8
+ isOmpStructuredSettingPath,
8
9
  type listOmpSettings,
9
10
  OMP_SETTINGS_CATALOG_VERSION,
10
- type OmpScalarValue,
11
11
  type OmpSetting,
12
12
  type OmpSettingType,
13
13
  OmpSettingTypeSchema,
14
+ parseOmpStructuredSettingValue,
14
15
  type updateOmpSettings,
15
16
  } from "../shared/omp-settings";
16
17
  import { SerialMutationQueue } from "./mutation-queue";
@@ -305,14 +306,28 @@ async function loadCatalog(
305
306
  }
306
307
  }
307
308
 
308
- function serializeScalar(type: OmpSettingType, value: OmpScalarValue): string | null {
309
- if (type === "boolean") return typeof value === "boolean" ? String(value) : null;
310
- if (type === "number")
309
+ function serializeSettingValue(setting: OmpSetting, value: unknown): string | null {
310
+ if (isOmpStructuredSettingPath(setting.path)) {
311
+ const expectedType = setting.path === "cycleOrder" ? "array" : "record";
312
+ if (setting.type !== expectedType) return null;
313
+ const parsed = parseOmpStructuredSettingValue(setting.path, value);
314
+ return parsed === undefined ? null : JSON.stringify(parsed);
315
+ }
316
+ if (setting.type === "boolean") return typeof value === "boolean" ? String(value) : null;
317
+ if (setting.type === "number")
311
318
  return typeof value === "number" && Number.isFinite(value) ? String(value) : null;
312
- if (type === "string" || type === "enum") return typeof value === "string" ? value : null;
319
+ if (setting.type === "string" || setting.type === "enum")
320
+ return typeof value === "string" ? value : null;
313
321
  return null;
314
322
  }
315
323
 
324
+ function isEditableSetting(setting: OmpSetting): boolean {
325
+ if (setting.redacted) return false;
326
+ if (["boolean", "number", "string", "enum"].includes(setting.type)) return true;
327
+ if (!isOmpStructuredSettingPath(setting.path)) return false;
328
+ return setting.type === (setting.path === "cycleOrder" ? "array" : "record");
329
+ }
330
+
316
331
  export async function listOmpSettingsWithDependencies(
317
332
  input: RpcInput<typeof listOmpSettings>,
318
333
  dependencies: OmpSettingsDependencies,
@@ -373,26 +388,22 @@ export async function updateOmpSettingsWithDependencies(
373
388
  if (input.cwd) {
374
389
  for (const change of input.changes) {
375
390
  const setting = byPath.get(change.path);
376
- if (
377
- !setting ||
378
- setting.redacted ||
379
- !["boolean", "number", "string", "enum"].includes(setting.type)
380
- ) {
391
+ if (!setting || !isEditableSetting(setting)) {
381
392
  return {
382
393
  conflict: false,
383
394
  appliedPaths: [],
384
395
  failed: {
385
396
  path: change.path,
386
- message: "This setting cannot be edited as a workspace scalar value.",
397
+ message: "This setting cannot be edited through the focused configuration editor.",
387
398
  },
388
399
  catalog: current,
389
400
  };
390
401
  }
391
- if (change.operation === "set" && serializeScalar(setting.type, change.value) === null) {
402
+ if (change.operation === "set" && serializeSettingValue(setting, change.value) === null) {
392
403
  return {
393
404
  conflict: false,
394
405
  appliedPaths: [],
395
- failed: { path: change.path, message: `Expected a ${setting.type} value.` },
406
+ failed: { path: change.path, message: `Invalid ${change.path} value.` },
396
407
  catalog: current,
397
408
  };
398
409
  }
@@ -440,15 +451,14 @@ export async function updateOmpSettingsWithDependencies(
440
451
  const appliedPaths: string[] = [];
441
452
  for (const change of input.changes) {
442
453
  const setting = byPath.get(change.path);
443
- if (
444
- !setting ||
445
- setting.redacted ||
446
- !["boolean", "number", "string", "enum"].includes(setting.type)
447
- ) {
454
+ if (!setting || !isEditableSetting(setting)) {
448
455
  return {
449
456
  conflict: false,
450
457
  appliedPaths,
451
- failed: { path: change.path, message: "This setting cannot be edited as a scalar value." },
458
+ failed: {
459
+ path: change.path,
460
+ message: "This setting cannot be edited through the focused configuration editor.",
461
+ },
452
462
  catalog: await loadCatalog(executable, dependencies, input.cwd),
453
463
  };
454
464
  }
@@ -456,7 +466,7 @@ export async function updateOmpSettingsWithDependencies(
456
466
  if (change.operation === "reset") {
457
467
  args = ["reset", change.path];
458
468
  } else {
459
- const value = serializeScalar(setting.type, change.value);
469
+ const value = serializeSettingValue(setting, change.value);
460
470
  args = value === null ? null : ["set", change.path, "--json", "--", value];
461
471
  }
462
472
  if (!args) {
@@ -0,0 +1,76 @@
1
+ export const OMP_OPERATIONAL_FAILURES = [
2
+ { category: "session-open", stage: "startup" },
3
+ { category: "session-open", stage: "catalog" },
4
+ { category: "replay-recovery", stage: "persisted-replay" },
5
+ { category: "replay-recovery", stage: "runtime-recovery" },
6
+ { category: "replay-recovery", stage: "rewind" },
7
+ { category: "tool-projector", stage: "host-tool-unknown" },
8
+ { category: "tool-projector", stage: "host-tool-capacity" },
9
+ { category: "tool-projector", stage: "host-tool-normalization" },
10
+ { category: "tool-projector", stage: "host-tool-call" },
11
+ { category: "tool-projector", stage: "host-tool-timeout" },
12
+ { category: "tool-projector", stage: "host-tool-delivery" },
13
+ { category: "tool-projector", stage: "timeline-projector" },
14
+ { category: "tool-projector", stage: "subsession-projector" },
15
+ { category: "terminal-outcome", stage: "failed" },
16
+ { category: "terminal-outcome", stage: "unresolved" },
17
+ ] as const;
18
+
19
+ export type OmpOperationalFailure = (typeof OMP_OPERATIONAL_FAILURES)[number];
20
+ export type OmpOperationalFailureCategory = OmpOperationalFailure["category"];
21
+ export type OmpOperationalFailureStage = OmpOperationalFailure["stage"];
22
+ export type OmpOperationalFailureReporter = (failure: OmpOperationalFailure) => void;
23
+
24
+ export type OmpOperationalFailureSummary = OmpOperationalFailure & {
25
+ occurrenceCount: number;
26
+ firstAt: string | null;
27
+ lastAt: string | null;
28
+ };
29
+
30
+ const FAILURE_KEYS: Readonly<Record<string, true>> = Object.fromEntries(
31
+ OMP_OPERATIONAL_FAILURES.map(({ category, stage }) => [`${category}:${stage}`, true]),
32
+ );
33
+
34
+ function timestamp(now: () => Date): string | null {
35
+ try {
36
+ const value = now();
37
+ return Number.isFinite(value.getTime()) ? value.toISOString() : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** Fixed category/stage cells make aggregation constant-space for one plugin subprocess lifetime. */
44
+ export class OmpOperationalFailureCollector {
45
+ private readonly summaries = Object.fromEntries(
46
+ OMP_OPERATIONAL_FAILURES.map(({ category, stage }) => [
47
+ `${category}:${stage}`,
48
+ { category, stage, occurrenceCount: 0, firstAt: null, lastAt: null },
49
+ ]),
50
+ ) as Record<string, OmpOperationalFailureSummary>;
51
+
52
+ constructor(private readonly now: () => Date = () => new Date()) {}
53
+
54
+ report: OmpOperationalFailureReporter = (failure) => {
55
+ try {
56
+ const key = `${failure.category}:${failure.stage}`;
57
+ if (!FAILURE_KEYS[key]) return;
58
+ const summary = this.summaries[key];
59
+ if (!summary) return;
60
+ summary.occurrenceCount = Math.min(Number.MAX_SAFE_INTEGER, summary.occurrenceCount + 1);
61
+ const recordedAt = timestamp(this.now);
62
+ if (recordedAt) {
63
+ summary.firstAt ??= recordedAt;
64
+ summary.lastAt = recordedAt;
65
+ }
66
+ } catch {
67
+ // Diagnostics must never affect provider or session flow.
68
+ }
69
+ };
70
+
71
+ snapshot(): OmpOperationalFailureSummary[] {
72
+ return OMP_OPERATIONAL_FAILURES.map(({ category, stage }) => ({
73
+ ...this.summaries[`${category}:${stage}`],
74
+ }));
75
+ }
76
+ }
@@ -0,0 +1,2 @@
1
+ /** Release Please keeps this bundle-safe constant aligned with package.json. */
2
+ export const PASEO_OMP_PACKAGE_VERSION = "0.4.0-next.114.1"; // x-release-please-version
@@ -0,0 +1,169 @@
1
+ import {
2
+ OMP_PROTOCOL_VIOLATION_CATEGORIES,
3
+ OMP_PROTOCOL_VIOLATION_REASONS,
4
+ type OmpProtocolViolationCategory,
5
+ type OmpProtocolViolationDiagnostic,
6
+ type OmpProtocolViolationReason,
7
+ } from "./provider/omp-rpc";
8
+
9
+ export interface OmpProtocolViolationSummary {
10
+ category: OmpProtocolViolationCategory;
11
+ occurrenceCount: number;
12
+ batchCount: number;
13
+ maxOccurrenceCount: number;
14
+ firstAt: string | null;
15
+ lastAt: string | null;
16
+ reasonCounts: Record<OmpProtocolViolationReason, number>;
17
+ latestReason: OmpProtocolViolationDiagnostic["reason"] | null;
18
+ latestPhase: OmpProtocolViolationDiagnostic["phase"] | null;
19
+ latestEventType: OmpProtocolViolationDiagnostic["eventType"] | null;
20
+ latestFrameType: OmpProtocolViolationDiagnostic["frameType"] | null;
21
+ latestField: OmpProtocolViolationDiagnostic["field"] | null;
22
+ latestExpected: OmpProtocolViolationDiagnostic["expected"] | null;
23
+ latestActualType: OmpProtocolViolationDiagnostic["actualType"] | null;
24
+ maxByteSize: number | null;
25
+ latestLimitBytes: number | null;
26
+ }
27
+
28
+ type MutableSummary = OmpProtocolViolationSummary;
29
+
30
+ const FRAME_TYPES: Record<NonNullable<OmpProtocolViolationDiagnostic["frameType"]>, true> = {
31
+ ready: true,
32
+ response: true,
33
+ rpc_chunk: true,
34
+ rpc_frame_error: true,
35
+ notice: true,
36
+ };
37
+
38
+ function boundedCount(value: unknown): number {
39
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return 0;
40
+ return Math.min(value, Number.MAX_SAFE_INTEGER);
41
+ }
42
+
43
+ function boundedAdd(left: number, right: number): number {
44
+ return Math.min(Number.MAX_SAFE_INTEGER, left + right);
45
+ }
46
+
47
+ function safeTimestamp(now: () => Date): string | null {
48
+ try {
49
+ const value = now();
50
+ return Number.isFinite(value.getTime()) ? value.toISOString() : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function logProtocolViolation(message: string, diagnostic: OmpProtocolViolationDiagnostic): void {
57
+ const fields = [
58
+ `category: ${diagnostic.category}`,
59
+ `reason: ${diagnostic.reason}`,
60
+ `occurrenceCount: ${diagnostic.occurrenceCount}`,
61
+ `phase: ${diagnostic.phase}`,
62
+ ...(diagnostic.eventType ? [`eventType: ${diagnostic.eventType}`] : []),
63
+ ...(diagnostic.frameType ? [`frameType: ${diagnostic.frameType}`] : []),
64
+ ...(diagnostic.field ? [`field: ${diagnostic.field}`] : []),
65
+ ...(diagnostic.expected ? [`expected: ${diagnostic.expected}`] : []),
66
+ ...(diagnostic.actualType ? [`actualType: ${diagnostic.actualType}`] : []),
67
+ ...(diagnostic.maxByteSize ? [`maxByteSize: ${diagnostic.maxByteSize}`] : []),
68
+ ...(diagnostic.limitBytes ? [`limitBytes: ${diagnostic.limitBytes}`] : []),
69
+ ];
70
+ console.error(`${message} { ${fields.join(", ")} }`);
71
+ }
72
+
73
+ /** Fixed-category, saturating in-process aggregation. A new instance is created on each reload. */
74
+ export class OmpProtocolViolationCollector {
75
+ private readonly summaries = Object.fromEntries(
76
+ OMP_PROTOCOL_VIOLATION_CATEGORIES.map((category) => [
77
+ category,
78
+ {
79
+ category,
80
+ occurrenceCount: 0,
81
+ batchCount: 0,
82
+ maxOccurrenceCount: 0,
83
+ firstAt: null,
84
+ lastAt: null,
85
+ reasonCounts: Object.fromEntries(
86
+ OMP_PROTOCOL_VIOLATION_REASONS.map((reason) => [reason, 0]),
87
+ ),
88
+ latestReason: null,
89
+ latestPhase: null,
90
+ latestEventType: null,
91
+ latestFrameType: null,
92
+ latestField: null,
93
+ latestExpected: null,
94
+ latestActualType: null,
95
+ maxByteSize: null,
96
+ latestLimitBytes: null,
97
+ },
98
+ ]),
99
+ ) as Record<OmpProtocolViolationCategory, MutableSummary>;
100
+
101
+ constructor(
102
+ private readonly now: () => Date = () => new Date(),
103
+ private readonly log: (
104
+ message: string,
105
+ diagnostic: OmpProtocolViolationDiagnostic,
106
+ ) => void | PromiseLike<void> = logProtocolViolation,
107
+ ) {}
108
+
109
+ report = (diagnostic: OmpProtocolViolationDiagnostic): void => {
110
+ try {
111
+ const summary = this.summaries[diagnostic.category];
112
+ if (!summary) return;
113
+ const occurrenceCount = boundedCount(diagnostic.occurrenceCount);
114
+ const timestamp = safeTimestamp(this.now);
115
+ summary.occurrenceCount = boundedAdd(summary.occurrenceCount, occurrenceCount);
116
+ summary.batchCount = boundedAdd(summary.batchCount, 1);
117
+ summary.maxOccurrenceCount = Math.max(summary.maxOccurrenceCount, occurrenceCount);
118
+ if (timestamp) {
119
+ summary.firstAt ??= timestamp;
120
+ summary.lastAt = timestamp;
121
+ }
122
+ summary.reasonCounts[diagnostic.reason] = boundedAdd(
123
+ summary.reasonCounts[diagnostic.reason],
124
+ occurrenceCount,
125
+ );
126
+ summary.latestReason = diagnostic.reason;
127
+ summary.latestPhase = diagnostic.phase;
128
+ summary.latestEventType = diagnostic.eventType ?? null;
129
+ summary.latestFrameType =
130
+ diagnostic.frameType && FRAME_TYPES[diagnostic.frameType] ? diagnostic.frameType : null;
131
+ summary.latestField = diagnostic.field ?? null;
132
+ summary.latestExpected = diagnostic.expected ?? null;
133
+ summary.latestActualType = diagnostic.actualType ?? null;
134
+ const byteSize = boundedCount(diagnostic.maxByteSize);
135
+ if (byteSize > 0) summary.maxByteSize = Math.max(summary.maxByteSize ?? 0, byteSize);
136
+ const limitBytes = boundedCount(diagnostic.limitBytes);
137
+ summary.latestLimitBytes = limitBytes || null;
138
+
139
+ const safeDiagnostic: OmpProtocolViolationDiagnostic = {
140
+ category: diagnostic.category,
141
+ reason: diagnostic.reason,
142
+ phase: diagnostic.phase,
143
+ occurrenceCount,
144
+ ...(summary.latestEventType ? { eventType: summary.latestEventType } : {}),
145
+ ...(summary.latestFrameType ? { frameType: summary.latestFrameType } : {}),
146
+ ...(summary.latestField ? { field: summary.latestField } : {}),
147
+ ...(summary.latestExpected ? { expected: summary.latestExpected } : {}),
148
+ ...(summary.latestActualType ? { actualType: summary.latestActualType } : {}),
149
+ ...(byteSize > 0 ? { maxByteSize: byteSize } : {}),
150
+ ...(limitBytes > 0 ? { limitBytes } : {}),
151
+ };
152
+ try {
153
+ const logging = this.log("OMP protocol violation", safeDiagnostic);
154
+ if (logging) void Promise.resolve(logging).catch(() => undefined);
155
+ } catch {
156
+ // Diagnostics must never affect transport or provider flow.
157
+ }
158
+ } catch {
159
+ // Diagnostics must never affect transport or provider flow.
160
+ }
161
+ };
162
+
163
+ snapshot(): OmpProtocolViolationSummary[] {
164
+ return OMP_PROTOCOL_VIOLATION_CATEGORIES.map((category) => ({
165
+ ...this.summaries[category],
166
+ reasonCounts: { ...this.summaries[category].reasonCounts },
167
+ }));
168
+ }
169
+ }
@@ -48,6 +48,27 @@ const THINKING_OPTIONS: readonly ProviderThinkingOption[] = [
48
48
  { id: "xhigh", label: "XHigh", description: "Extra-high reasoning" },
49
49
  { id: "max", label: "Max", description: "Maximum reasoning" },
50
50
  ];
51
+ export const OMP_MAX_CATALOG_MODELS = 256;
52
+
53
+ export function selectOmpModels(
54
+ models: readonly OmpModel[],
55
+ activeModel: OmpModel | null | undefined,
56
+ ): OmpModel[] {
57
+ if (models.length <= OMP_MAX_CATALOG_MODELS) return [...models];
58
+ const selected = models.slice(0, OMP_MAX_CATALOG_MODELS);
59
+ if (!activeModel) return selected;
60
+ const active = models.find(
61
+ (model) => model.provider === activeModel.provider && model.id === activeModel.id,
62
+ );
63
+ if (
64
+ !active ||
65
+ selected.some((model) => model.provider === active.provider && model.id === active.id)
66
+ ) {
67
+ return selected;
68
+ }
69
+ selected[OMP_MAX_CATALOG_MODELS - 1] = active;
70
+ return selected;
71
+ }
51
72
 
52
73
  export function nativeOmpModelId(model: OmpModel): string {
53
74
  if (model.provider.includes("/")) {
@@ -60,14 +81,9 @@ export function ompModelId(model: OmpModel): string {
60
81
  const nativeIdentity = `${Buffer.byteLength(model.provider, "utf8")}:${model.provider}${Buffer.byteLength(model.id, "utf8")}:${model.id}`;
61
82
  return `omp:model:${createHash("sha256").update(nativeIdentity).digest("hex")}`;
62
83
  }
63
-
64
- export function mapOmpModels(
65
- models: readonly OmpModel[],
66
- serializer = new OmpPublicDataSerializer(),
67
- ): ProviderModel[] {
84
+ export function validateOmpModelIdentities(models: readonly OmpModel[]): void {
68
85
  const seenIds = new Map<string, string>();
69
- return models.map((model) => {
70
- const thinkingOptions = thinkingForModel(model);
86
+ for (const model of models) {
71
87
  const id = ompModelId(model);
72
88
  const nativeIdentity = nativeOmpModelId(model);
73
89
  const existing = seenIds.get(id);
@@ -76,6 +92,17 @@ export function mapOmpModels(
76
92
  }
77
93
  if (existing !== undefined) throw new Error("OMP reported a duplicate model identity");
78
94
  seenIds.set(id, nativeIdentity);
95
+ }
96
+ }
97
+
98
+ export function mapOmpModels(
99
+ models: readonly OmpModel[],
100
+ serializer = new OmpPublicDataSerializer(),
101
+ ): ProviderModel[] {
102
+ validateOmpModelIdentities(models);
103
+ return models.map((model) => {
104
+ const thinkingOptions = thinkingForModel(model);
105
+ const id = ompModelId(model);
79
106
  const provider = serializer.text(model.provider, 256);
80
107
  const modelId = serializer.text(model.id, 256);
81
108
  const name = model.name ? serializer.text(model.name, 256) : modelId;
@@ -143,14 +170,16 @@ export async function discoverOmpCatalog(
143
170
  ? [...configuredValues, ...(session.inheritedRedactionValues ?? [])]
144
171
  : configuredValues,
145
172
  );
146
- const models = mapOmpModels(nativeModels, serializer);
173
+ validateOmpModelIdentities(nativeModels);
174
+ const selectedNativeModels = selectOmpModels(nativeModels, state.model);
175
+ const models = mapOmpModels(selectedNativeModels, serializer);
147
176
  if (models.length === 0) throw new Error("OMP reported no available models");
148
177
  const defaultModel = state.model ? ompModelId(state.model) : models[0]?.id;
149
178
  const currentModel = state.model
150
- ? nativeModels.find(
179
+ ? selectedNativeModels.find(
151
180
  (model) => model.provider === state.model?.provider && model.id === state.model.id,
152
181
  )
153
- : nativeModels[0];
182
+ : selectedNativeModels[0];
154
183
  if (state.model && !currentModel) throw new Error("OMP reported an unadvertised active model");
155
184
  const thinkingOptions = thinkingForModel(currentModel);
156
185
  const defaultThinkingOption = thinkingOptions.some(