@bitkyc08/opencodex 2.7.23 → 2.7.24

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 (55) hide show
  1. package/README.ko.md +37 -7
  2. package/README.md +52 -11
  3. package/README.zh-CN.md +36 -7
  4. package/bin/ocx.mjs +5 -3
  5. package/gui/dist/assets/index-BzhyTAco.js +40 -0
  6. package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/opencode.svg +1 -1
  9. package/package.json +5 -2
  10. package/src/adapters/anthropic-image-normalize.ts +70 -29
  11. package/src/adapters/cursor/transport-retry.ts +20 -1
  12. package/src/adapters/run-turn-queue.ts +40 -0
  13. package/src/codex/auth-api.ts +10 -1
  14. package/src/codex/auth-context.ts +33 -7
  15. package/src/codex/catalog.ts +357 -23
  16. package/src/codex/routing.ts +10 -4
  17. package/src/combos/failover.ts +102 -0
  18. package/src/combos/index.ts +37 -0
  19. package/src/combos/request.ts +31 -0
  20. package/src/combos/resolve.ts +171 -0
  21. package/src/combos/types.ts +203 -0
  22. package/src/config.ts +280 -11
  23. package/src/lib/errors.ts +86 -24
  24. package/src/lib/upstream-retry.ts +8 -4
  25. package/src/oauth/index.ts +7 -1
  26. package/src/oauth/key-providers.ts +2 -32
  27. package/src/oauth/login-cli.ts +4 -3
  28. package/src/oauth/token-guardian.ts +38 -3
  29. package/src/providers/derive.ts +27 -2
  30. package/src/providers/kiro-models.ts +8 -3
  31. package/src/providers/label.ts +3 -1
  32. package/src/providers/openai-sidecar.ts +94 -0
  33. package/src/providers/openai-tier-startup.ts +27 -0
  34. package/src/providers/openai-tiers.ts +283 -0
  35. package/src/providers/openai-virtual-models.ts +82 -0
  36. package/src/providers/quota.ts +344 -24
  37. package/src/providers/registry.ts +112 -20
  38. package/src/reasoning-effort.ts +12 -11
  39. package/src/router.ts +80 -36
  40. package/src/server/auth-cors.ts +85 -9
  41. package/src/server/images.ts +31 -75
  42. package/src/server/index.ts +45 -86
  43. package/src/server/management-api.ts +273 -21
  44. package/src/server/request-log.ts +221 -20
  45. package/src/server/responses.ts +594 -75
  46. package/src/server/search.ts +22 -37
  47. package/src/types.ts +49 -1
  48. package/src/update/index.ts +50 -6
  49. package/src/update/job.ts +21 -4
  50. package/src/usage/log.ts +124 -1
  51. package/src/usage/summary.ts +147 -56
  52. package/src/vision/index.ts +20 -19
  53. package/src/web-search/index.ts +15 -17
  54. package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
  55. package/gui/dist/assets/index-DQjt6Hly.js +0 -40
@@ -0,0 +1,102 @@
1
+ import { classifyError } from "../lib/errors";
2
+ import type { OcxComboTarget } from "../types";
3
+ import { targetKey } from "./types";
4
+
5
+ interface TargetCooldown {
6
+ cooldownUntil: number;
7
+ }
8
+
9
+ const DEFAULT_COOLDOWN_MS = 60_000;
10
+ const MAX_COOLDOWN_MS = 10 * 60_000;
11
+
12
+ /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */
13
+ const targetCooldowns = new Map<string, TargetCooldown>();
14
+
15
+ function cooldownMapKey(
16
+ comboId: string,
17
+ target: Pick<OcxComboTarget, "provider" | "model">,
18
+ ): string {
19
+ return `${comboId}\0${targetKey(target)}`;
20
+ }
21
+
22
+ export function parseRetryAfterMs(
23
+ value: string | null | undefined,
24
+ now = Date.now(),
25
+ ): number | undefined {
26
+ const text = value?.trim();
27
+ if (!text) return undefined;
28
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
29
+ const seconds = Number(text);
30
+ if (Number.isFinite(seconds) && seconds > 0) {
31
+ return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
32
+ }
33
+ }
34
+ const timestamp = Date.parse(text);
35
+ if (!Number.isFinite(timestamp)) return undefined;
36
+ const delay = timestamp - now;
37
+ return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
38
+ }
39
+
40
+ export function isComboTargetInCooldown(
41
+ comboId: string,
42
+ target: Pick<OcxComboTarget, "provider" | "model">,
43
+ now = Date.now(),
44
+ ): boolean {
45
+ const key = cooldownMapKey(comboId, target);
46
+ const entry = targetCooldowns.get(key);
47
+ if (!entry) return false;
48
+ if (entry.cooldownUntil <= now) {
49
+ targetCooldowns.delete(key);
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ export function coolComboTarget(
56
+ comboId: string,
57
+ target: Pick<OcxComboTarget, "provider" | "model">,
58
+ options?: { retryAfter?: string | null; now?: number; cooldownMs?: number },
59
+ ): void {
60
+ const now = options?.now ?? Date.now();
61
+ const cooldownMs = options?.cooldownMs
62
+ ?? parseRetryAfterMs(options?.retryAfter, now)
63
+ ?? DEFAULT_COOLDOWN_MS;
64
+ targetCooldowns.set(cooldownMapKey(comboId, target), {
65
+ cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
66
+ });
67
+ }
68
+
69
+ export function clearComboTargetCooldowns(comboId?: string): void {
70
+ if (comboId === undefined) {
71
+ targetCooldowns.clear();
72
+ return;
73
+ }
74
+ const prefix = `${comboId}\0`;
75
+ for (const key of targetCooldowns.keys()) {
76
+ if (key.startsWith(prefix)) targetCooldowns.delete(key);
77
+ }
78
+ }
79
+
80
+ export type ComboFailureDecision = "hop" | "stop";
81
+
82
+ export function comboFailureDecision(status: number, message: string): ComboFailureDecision {
83
+ if (status === 499) return "stop";
84
+ if (message.toLowerCase().includes("origin_rejected")) return "stop";
85
+ const error = classifyError(status, "upstream_error", message);
86
+ if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
87
+ return "stop";
88
+ }
89
+ if ([401, 403, 404, 408, 429].includes(status) || status >= 500) return "hop";
90
+ if ([
91
+ "permission_denied",
92
+ "subscription_required",
93
+ "invalid_api_key",
94
+ "insufficient_quota",
95
+ "rate_limit_exceeded",
96
+ "server_is_overloaded",
97
+ "upstream_server_error",
98
+ ].includes(error.code ?? "")) {
99
+ return "hop";
100
+ }
101
+ return "stop";
102
+ }
@@ -0,0 +1,37 @@
1
+ export {
2
+ COMBO_DEFAULT_EFFORT,
3
+ COMBO_NAMESPACE,
4
+ comboConfigError,
5
+ comboConfigIssues,
6
+ comboDefaultEffort,
7
+ comboModelId,
8
+ getCombo,
9
+ isValidComboId,
10
+ listComboIds,
11
+ normalizeComboConfig,
12
+ parseComboModelId,
13
+ targetKey,
14
+ } from "./types";
15
+ export {
16
+ advanceComboAfterFailure,
17
+ clearComboSelectionState,
18
+ NoAvailableComboTargetsError,
19
+ noteComboFailure,
20
+ noteComboSuccess,
21
+ pickComboTarget,
22
+ tryPickComboModel,
23
+ UnknownComboError,
24
+ type ComboPick,
25
+ } from "./resolve";
26
+ export {
27
+ clearComboTargetCooldowns,
28
+ coolComboTarget,
29
+ isComboTargetInCooldown,
30
+ parseRetryAfterMs,
31
+ comboFailureDecision,
32
+ type ComboFailureDecision,
33
+ } from "./failover";
34
+ export {
35
+ comboIdFromRawBody,
36
+ concreteComboRequestBody,
37
+ } from "./request";
@@ -0,0 +1,31 @@
1
+ import type { OcxComboDefaultEffort, OcxComboTarget } from "../types";
2
+ import { parseComboModelId } from "./types";
3
+
4
+ export function comboIdFromRawBody(body: unknown): string | null {
5
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
6
+ const model = (body as { model?: unknown }).model;
7
+ if (typeof model !== "string") return null;
8
+ return parseComboModelId(model);
9
+ }
10
+
11
+ export function concreteComboRequestBody(
12
+ body: unknown,
13
+ target: Pick<OcxComboTarget, "provider" | "model">,
14
+ defaultEffort: OcxComboDefaultEffort | null,
15
+ ): Record<string, unknown> {
16
+ const clone = structuredClone(body) as Record<string, unknown>;
17
+ clone.model = `${target.provider}/${target.model}`;
18
+ if (!defaultEffort) return clone;
19
+ const reasoning = clone.reasoning;
20
+ if (reasoning === undefined) {
21
+ clone.reasoning = { effort: defaultEffort };
22
+ } else if (
23
+ reasoning
24
+ && typeof reasoning === "object"
25
+ && !Array.isArray(reasoning)
26
+ && !Object.prototype.hasOwnProperty.call(reasoning, "effort")
27
+ ) {
28
+ clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: defaultEffort };
29
+ }
30
+ return clone;
31
+ }
@@ -0,0 +1,171 @@
1
+ import type { OcxComboTarget, OcxConfig } from "../types";
2
+ import { coolComboTarget, isComboTargetInCooldown } from "./failover";
3
+ import { getCombo, parseComboModelId, targetKey } from "./types";
4
+ import type { NormalizedComboConfig } from "./types";
5
+
6
+ export interface ComboPick {
7
+ comboId: string;
8
+ target: Required<OcxComboTarget>;
9
+ targetIndex: number;
10
+ attempted: string[];
11
+ }
12
+
13
+ interface SelectionState {
14
+ activeKey?: string;
15
+ successes: number;
16
+ currentWeights: Map<string, number>;
17
+ }
18
+
19
+ const selectionState = new Map<string, SelectionState>();
20
+
21
+ export class UnknownComboError extends Error {
22
+ constructor(readonly comboId: string) {
23
+ super(`Unknown combo: ${comboId}`);
24
+ this.name = "UnknownComboError";
25
+ }
26
+ }
27
+
28
+ export class NoAvailableComboTargetsError extends Error {
29
+ readonly code = "combo_unavailable";
30
+
31
+ constructor(readonly comboId: string) {
32
+ super(`No available targets for combo: ${comboId}`);
33
+ this.name = "NoAvailableComboTargetsError";
34
+ }
35
+ }
36
+
37
+ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget): boolean {
38
+ return Object.hasOwn(config.providers, target.provider)
39
+ && config.providers[target.provider]?.disabled !== true;
40
+ }
41
+
42
+ function smoothWeightedIndex(
43
+ targets: Required<OcxComboTarget>[],
44
+ state: SelectionState,
45
+ eligible: (target: Required<OcxComboTarget>) => boolean,
46
+ ): number {
47
+ let best = -1;
48
+ let bestScore = Number.NEGATIVE_INFINITY;
49
+ let total = 0;
50
+ for (let i = 0; i < targets.length; i++) {
51
+ const target = targets[i]!;
52
+ if (!eligible(target)) continue;
53
+ const key = targetKey(target);
54
+ const score = (state.currentWeights.get(key) ?? 0) + target.weight;
55
+ state.currentWeights.set(key, score);
56
+ total += target.weight;
57
+ if (score > bestScore) {
58
+ best = i;
59
+ bestScore = score;
60
+ }
61
+ }
62
+ if (best >= 0) {
63
+ const key = targetKey(targets[best]!);
64
+ state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total);
65
+ }
66
+ return best;
67
+ }
68
+
69
+ export function pickComboTarget(
70
+ config: OcxConfig,
71
+ comboId: string,
72
+ options: {
73
+ exclude?: Iterable<string>;
74
+ eligible?: (target: Required<OcxComboTarget>) => boolean;
75
+ } = {},
76
+ ): ComboPick | null {
77
+ const combo = getCombo(config, comboId);
78
+ if (!combo) throw new UnknownComboError(comboId);
79
+ const excluded = new Set(options.exclude ?? []);
80
+ const eligible = (target: Required<OcxComboTarget>): boolean =>
81
+ targetProviderIsUsable(config, target)
82
+ && !excluded.has(targetKey(target))
83
+ && (options.eligible?.(target) ?? true);
84
+
85
+ let targetIndex = -1;
86
+ if (combo.strategy === "round-robin") {
87
+ let state = selectionState.get(comboId);
88
+ if (!state) {
89
+ state = { successes: 0, currentWeights: new Map() };
90
+ selectionState.set(comboId, state);
91
+ }
92
+ if (state.activeKey) {
93
+ targetIndex = combo.targets.findIndex(target => targetKey(target) === state.activeKey && eligible(target));
94
+ if (targetIndex < 0) {
95
+ delete state.activeKey;
96
+ state.successes = 0;
97
+ }
98
+ }
99
+ if (targetIndex < 0) {
100
+ targetIndex = smoothWeightedIndex(combo.targets, state, eligible);
101
+ if (targetIndex >= 0) {
102
+ state.activeKey = targetKey(combo.targets[targetIndex]!);
103
+ state.successes = 0;
104
+ }
105
+ }
106
+ } else {
107
+ targetIndex = combo.targets.findIndex(eligible);
108
+ }
109
+
110
+ if (targetIndex < 0) return null;
111
+ const target = combo.targets[targetIndex]!;
112
+ return {
113
+ comboId,
114
+ target,
115
+ targetIndex,
116
+ attempted: [...excluded, targetKey(target)],
117
+ };
118
+ }
119
+
120
+ export function noteComboSuccess(
121
+ comboId: string,
122
+ combo: NormalizedComboConfig,
123
+ target: Required<OcxComboTarget>,
124
+ ): void {
125
+ if (combo.strategy !== "round-robin") return;
126
+ const state = selectionState.get(comboId);
127
+ if (!state || state.activeKey !== targetKey(target)) return;
128
+ state.successes += 1;
129
+ if (state.successes >= combo.stickyLimit) {
130
+ delete state.activeKey;
131
+ state.successes = 0;
132
+ }
133
+ }
134
+
135
+ export function noteComboFailure(comboId: string, target: OcxComboTarget): void {
136
+ const state = selectionState.get(comboId);
137
+ if (state?.activeKey === targetKey(target)) {
138
+ delete state.activeKey;
139
+ state.successes = 0;
140
+ }
141
+ }
142
+
143
+ export function advanceComboAfterFailure(
144
+ config: OcxConfig,
145
+ pick: ComboPick,
146
+ options: { retryAfter?: string | null; now?: number } = {},
147
+ ): ComboPick | null {
148
+ noteComboFailure(pick.comboId, pick.target);
149
+ coolComboTarget(pick.comboId, pick.target, options);
150
+ return pickComboTarget(config, pick.comboId, {
151
+ exclude: pick.attempted,
152
+ eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now),
153
+ });
154
+ }
155
+
156
+ export function clearComboSelectionState(comboId?: string): void {
157
+ if (comboId === undefined) {
158
+ selectionState.clear();
159
+ return;
160
+ }
161
+ selectionState.delete(comboId);
162
+ }
163
+
164
+ export function tryPickComboModel(config: OcxConfig, modelId: string): ComboPick | null {
165
+ const comboId = parseComboModelId(modelId);
166
+ if (!comboId) return null;
167
+ if (!getCombo(config, comboId)) throw new UnknownComboError(comboId);
168
+ const picked = pickComboTarget(config, comboId);
169
+ if (!picked) throw new NoAvailableComboTargetsError(comboId);
170
+ return picked;
171
+ }
@@ -0,0 +1,203 @@
1
+ import { isCodexReasoningEffort } from "../reasoning-effort";
2
+ import type {
3
+ OcxComboConfig,
4
+ OcxComboDefaultEffort,
5
+ OcxComboStrategy,
6
+ OcxComboTarget,
7
+ OcxProviderConfig,
8
+ } from "../types";
9
+
10
+ export const COMBO_NAMESPACE = "combo";
11
+ export const COMBO_DEFAULT_EFFORT: OcxComboDefaultEffort = "medium";
12
+ const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
13
+
14
+ export interface ComboValidationIssue {
15
+ path: Array<string | number>;
16
+ message: string;
17
+ }
18
+
19
+ export interface NormalizedComboConfig {
20
+ strategy: OcxComboStrategy;
21
+ stickyLimit: number;
22
+ defaultEffort: OcxComboDefaultEffort;
23
+ targets: Array<Required<OcxComboTarget>>;
24
+ }
25
+
26
+ export function targetKey(target: Pick<OcxComboTarget, "provider" | "model">): string {
27
+ return `${target.provider}/${target.model}`;
28
+ }
29
+
30
+ export function parseComboModelId(modelId: string): string | null {
31
+ const slash = modelId.indexOf("/");
32
+ if (slash <= 0 || modelId.slice(0, slash) !== COMBO_NAMESPACE) return null;
33
+ const id = modelId.slice(slash + 1);
34
+ return id.length > 0 ? id : null;
35
+ }
36
+
37
+ export function comboModelId(id: string): string {
38
+ return `${COMBO_NAMESPACE}/${id}`;
39
+ }
40
+
41
+ export function comboConfigIssues(
42
+ id: string,
43
+ raw: unknown,
44
+ providers: Record<string, OcxProviderConfig>,
45
+ options: { requireEnabledTarget?: boolean } = {},
46
+ ): ComboValidationIssue[] {
47
+ const issues: ComboValidationIssue[] = [];
48
+ if (!isValidComboId(id)) {
49
+ issues.push({
50
+ path: [],
51
+ message: "combo id must start with a letter/number and use letters, numbers, dot, underscore, or hyphen (max 64)",
52
+ });
53
+ }
54
+ if (Object.hasOwn(providers, COMBO_NAMESPACE)) {
55
+ issues.push({
56
+ path: [],
57
+ message: 'provider name "combo" collides with the reserved "combo/" namespace while combos are configured',
58
+ });
59
+ }
60
+ if (Object.hasOwn(providers, id)) {
61
+ issues.push({
62
+ path: [],
63
+ message: `combo id "${id}" collides with configured provider name "${id}"`,
64
+ });
65
+ }
66
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
67
+ issues.push({ path: [], message: "combo must be an object" });
68
+ return issues;
69
+ }
70
+
71
+ const body = raw as Record<string, unknown>;
72
+ if (body.strategy !== undefined
73
+ && body.strategy !== "failover"
74
+ && body.strategy !== "round-robin") {
75
+ issues.push({ path: ["strategy"], message: 'strategy must be "failover" or "round-robin"' });
76
+ }
77
+ if (body.stickyLimit !== undefined
78
+ && (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit)
79
+ || body.stickyLimit < 1
80
+ || body.stickyLimit > 100)) {
81
+ issues.push({ path: ["stickyLimit"], message: "stickyLimit must be an integer from 1 to 100" });
82
+ }
83
+ if (body.defaultEffort !== undefined
84
+ && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) {
85
+ issues.push({
86
+ path: ["defaultEffort"],
87
+ message: "defaultEffort must be one of: low, medium, high, xhigh, max, ultra",
88
+ });
89
+ }
90
+
91
+ if (!Array.isArray(body.targets) || body.targets.length === 0) {
92
+ issues.push({ path: ["targets"], message: "targets must be a non-empty array" });
93
+ return issues;
94
+ }
95
+
96
+ const seen = new Set<string>();
97
+ let configuredProviderCount = 0;
98
+ let enabledProviderCount = 0;
99
+ for (let i = 0; i < body.targets.length; i++) {
100
+ const rawTarget = body.targets[i];
101
+ if (!rawTarget || typeof rawTarget !== "object" || Array.isArray(rawTarget)) {
102
+ issues.push({ path: ["targets", i], message: `targets[${i}] must be an object` });
103
+ continue;
104
+ }
105
+ const target = rawTarget as Record<string, unknown>;
106
+ const provider = typeof target.provider === "string" ? target.provider.trim() : "";
107
+ const model = typeof target.model === "string" ? target.model.trim() : "";
108
+
109
+ if (!provider) {
110
+ issues.push({ path: ["targets", i, "provider"], message: `targets[${i}].provider is required` });
111
+ } else if (!Object.hasOwn(providers, provider)) {
112
+ issues.push({
113
+ path: ["targets", i, "provider"],
114
+ message: `targets[${i}].provider "${provider}" is not configured`,
115
+ });
116
+ } else {
117
+ configuredProviderCount += 1;
118
+ if (providers[provider]?.disabled !== true) enabledProviderCount += 1;
119
+ }
120
+
121
+ if (!model) {
122
+ issues.push({ path: ["targets", i, "model"], message: `targets[${i}].model is required` });
123
+ }
124
+ if (target.weight !== undefined
125
+ && (typeof target.weight !== "number" || !Number.isInteger(target.weight)
126
+ || target.weight < 1
127
+ || target.weight > 10_000)) {
128
+ issues.push({
129
+ path: ["targets", i, "weight"],
130
+ message: `targets[${i}].weight must be an integer from 1 to 10000`,
131
+ });
132
+ }
133
+
134
+ if (provider && model) {
135
+ const key = targetKey({ provider, model });
136
+ if (seen.has(key)) {
137
+ issues.push({ path: ["targets", i], message: `duplicate combo target "${key}"` });
138
+ } else {
139
+ seen.add(key);
140
+ }
141
+ }
142
+ }
143
+ if (options.requireEnabledTarget
144
+ && configuredProviderCount === body.targets.length
145
+ && enabledProviderCount === 0) {
146
+ issues.push({
147
+ path: ["targets"],
148
+ message: "targets must include at least one enabled provider",
149
+ });
150
+ }
151
+ return issues;
152
+ }
153
+
154
+ export function comboConfigError(
155
+ id: string,
156
+ raw: unknown,
157
+ providers: Record<string, OcxProviderConfig>,
158
+ options: { requireEnabledTarget?: boolean } = {},
159
+ ): string | null {
160
+ return comboConfigIssues(id, raw, providers, options)[0]?.message ?? null;
161
+ }
162
+
163
+ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig {
164
+ return {
165
+ strategy: raw.strategy ?? "failover",
166
+ stickyLimit: raw.stickyLimit ?? 1,
167
+ defaultEffort: raw.defaultEffort ?? COMBO_DEFAULT_EFFORT,
168
+ targets: raw.targets.map(target => ({
169
+ provider: target.provider.trim(),
170
+ model: target.model.trim(),
171
+ weight: target.weight ?? 1,
172
+ })),
173
+ };
174
+ }
175
+
176
+ export function comboDefaultEffort(
177
+ config: { combos?: Record<string, OcxComboConfig> },
178
+ id: string,
179
+ ): OcxComboDefaultEffort | null {
180
+ const combos = config.combos;
181
+ if (!combos || !Object.hasOwn(combos, id)) return null;
182
+ const value: unknown = combos[id]!.defaultEffort ?? COMBO_DEFAULT_EFFORT;
183
+ return typeof value === "string" && isCodexReasoningEffort(value)
184
+ ? value as OcxComboDefaultEffort
185
+ : null;
186
+ }
187
+
188
+ export function isValidComboId(id: string): boolean {
189
+ return COMBO_ID_PATTERN.test(id);
190
+ }
191
+
192
+ export function listComboIds(config: { combos?: Record<string, OcxComboConfig> }): string[] {
193
+ return Object.keys(config.combos ?? {}).sort((a, b) => a.localeCompare(b));
194
+ }
195
+
196
+ export function getCombo(
197
+ config: { combos?: Record<string, OcxComboConfig> },
198
+ id: string,
199
+ ): NormalizedComboConfig | undefined {
200
+ const combos = config.combos;
201
+ if (!combos || !Object.hasOwn(combos, id)) return undefined;
202
+ return normalizeComboConfig(combos[id]!);
203
+ }