@genee/omp-opsx-addon 0.8.0 → 0.10.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 +440 -71
- package/index.ts +1106 -235
- package/lib/agent-defs.ts +80 -89
- package/lib/complexity-router.ts +336 -0
- package/lib/concurrency-control.ts +1246 -0
- package/lib/concurrency-multiproc.worker.ts +376 -0
- package/lib/concurrency-state.ts +397 -0
- package/lib/concurrency-tuner.ts +773 -0
- package/lib/concurrency-writer.ts +894 -0
- package/lib/continuity-guard.ts +117 -0
- package/lib/family-filter.ts +115 -2
- package/lib/model-roles.ts +43 -41
- package/lib/model-selector.ts +300 -36
- package/lib/model-speed.ts +189 -0
- package/lib/pick-model-render.ts +414 -0
- package/lib/pipe-core.ts +303 -93
- package/lib/pipe-push.ts +212 -26
- package/lib/provider-variants.ts +154 -0
- package/lib/reachability-refresh.ts +10 -2
- package/lib/selection-filters.ts +42 -1
- package/lib/stall-detector.ts +185 -0
- package/lib/system-prompt.ts +28 -11
- package/lib/tiers-data.ts +47 -5
- package/lib/tiers-updater.ts +51 -8
- package/lib/unified-config.ts +735 -68
- package/lib/usage-render.ts +57 -4
- package/lib/usage-widget.ts +18 -4
- package/package.json +1 -1
- package/skills/opsx-orchestration-protocol/SKILL.md +87 -0
package/lib/pipe-push.ts
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
// there cannot preempt the running tool anyway.
|
|
28
28
|
import { promises as fs, watch } from 'fs';
|
|
29
29
|
import { join } from 'path';
|
|
30
|
-
import type
|
|
30
|
+
import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from '@oh-my-pi/pi-coding-agent';
|
|
31
|
+
import { Box, Markdown, Spacer, Text } from '@oh-my-pi/pi-tui';
|
|
31
32
|
import {
|
|
32
33
|
pipeRootDir,
|
|
33
34
|
ensureLayouts,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
type PipeMessage,
|
|
37
38
|
type DeliverMode,
|
|
38
39
|
} from './pipe-core.js';
|
|
40
|
+
import { PIPE_CONTRACT_LINES } from './system-prompt.js';
|
|
39
41
|
|
|
40
42
|
// ── constants ────────────────────────────────────────────────────────
|
|
41
43
|
|
|
@@ -45,6 +47,15 @@ export const PUSH_TICK_MS = 30_000;
|
|
|
45
47
|
/** customType for injected pipe messages (traceability, never user-spoofed). */
|
|
46
48
|
export const PIPE_CUSTOM_TYPE = 'opsx-pipe';
|
|
47
49
|
|
|
50
|
+
/** Fixed marker line delimiting the optional in-band peer-alignment contract tail. */
|
|
51
|
+
export const TAIL_MARKER_LINE = '<!-- opsx-pipe: peer-alignment-contract -->';
|
|
52
|
+
|
|
53
|
+
/** Fixed intro + shared contract bullets (no message-specific data). */
|
|
54
|
+
export const CONTRACT_TAIL_TEXT = [
|
|
55
|
+
'(契约随身副本——正本见常驻 prompt「跨 broker 对齐消息(pipe)」小节。)',
|
|
56
|
+
...PIPE_CONTRACT_LINES,
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
48
59
|
/** Process-global injector ownership: one push injector per process (the
|
|
49
60
|
* primary session). Mirrors usage-poller's Symbol.for tick-owner claim —
|
|
50
61
|
* subagent sessions also emit session_start in this process but must never
|
|
@@ -90,33 +101,133 @@ export function _setWatchFactoryForTest(factory: WatchFactory | null): void {
|
|
|
90
101
|
|
|
91
102
|
// ── pure helpers (unit-tested) ───────────────────────────────────────
|
|
92
103
|
|
|
104
|
+
/** Structural header of an injected pipe message, plus the raw body that
|
|
105
|
+
* follows the closing fence (design D4). */
|
|
106
|
+
export interface PipeHeader {
|
|
107
|
+
from: string;
|
|
108
|
+
kind: string;
|
|
109
|
+
id: string;
|
|
110
|
+
ts?: number;
|
|
111
|
+
session?: string;
|
|
112
|
+
body: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
116
|
+
* Build the injected content (design D1/D2): a YAML frontmatter structural
|
|
117
|
+
* header — `from` / `kind` / `id`, optional `ts`, optional `session` (last),
|
|
118
|
+
* both fences — then the raw body, separated by a single `\n`. Field values
|
|
119
|
+
* come from the message struct only (no clock, no randomness, no LLM text),
|
|
120
|
+
* so repeated injection of one message is byte-identical. String scalars are
|
|
121
|
+
* `JSON.stringify`-quoted (double quotes + standard escapes); `ts` stays a
|
|
122
|
+
* bare integer and its whole line is dropped when it is not a finite number
|
|
123
|
+
* (an older writer or a hand-made file may omit it). The header is layered on
|
|
124
|
+
* at injection time only and is never written back to the mbox. The fixed
|
|
125
|
+
* alignment contract lives in the plugin's static prompt (see
|
|
126
|
+
* lib/system-prompt.ts); when the landing turn's contract presence cannot be
|
|
127
|
+
* confirmed, the same shared bullets ride along as an in-band tail
|
|
128
|
+
* (`opts.contractTail`).
|
|
101
129
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
130
|
+
export function injectText(msg: PipeMessage, opts?: { contractTail?: boolean }): string {
|
|
131
|
+
const header = [
|
|
132
|
+
'---',
|
|
133
|
+
`from: ${JSON.stringify(msg.from.broker)}`,
|
|
134
|
+
'kind: "peer-alignment"',
|
|
135
|
+
`id: ${JSON.stringify(msg.id)}`,
|
|
136
|
+
];
|
|
137
|
+
if (typeof msg.ts === 'number' && Number.isFinite(msg.ts)) header.push(`ts: ${msg.ts}`);
|
|
138
|
+
// Conditional field last: without it the header is a strict prefix of the
|
|
139
|
+
// with-session form (assertions, diffs, forward-compatible fields).
|
|
140
|
+
if (typeof msg.to.session === 'string' && msg.to.session.length > 0) {
|
|
141
|
+
header.push(`session: ${JSON.stringify(msg.to.session)}`);
|
|
142
|
+
}
|
|
143
|
+
header.push('---');
|
|
144
|
+
const base = `${header.join('\n')}\n${msg.body}`;
|
|
145
|
+
if (!opts?.contractTail) return base;
|
|
146
|
+
return `${base}\n\n${TAIL_MARKER_LINE}\n${CONTRACT_TAIL_TEXT}`;
|
|
147
|
+
}
|
|
106
148
|
|
|
107
149
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* newlines preserved. When `to.session` names a sub-agent inside this broker,
|
|
111
|
-
* the fixed last-mile routing suffix (design D7) is appended as the final
|
|
112
|
-
* line. Deterministic and exactly assertable.
|
|
150
|
+
* Design D3 decision table: whether the injected content must carry the
|
|
151
|
+
* in-band contract tail. Pure function over inject-time observables.
|
|
113
152
|
*/
|
|
114
|
-
export function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
export function shouldCarryContractTail(opts: {
|
|
154
|
+
inTurn: boolean;
|
|
155
|
+
turnContractConfirmed: boolean;
|
|
156
|
+
deliverAs: DeliverMode;
|
|
157
|
+
triggerTurn: boolean;
|
|
158
|
+
}): boolean {
|
|
159
|
+
const { inTurn, turnContractConfirmed, deliverAs, triggerTurn } = opts;
|
|
160
|
+
if (!inTurn) return triggerTurn; // #1 carry / #2 omit
|
|
161
|
+
if (deliverAs === 'steer') return !turnContractConfirmed; // #3
|
|
162
|
+
if (deliverAs === 'followUp') return true; // #4 conservative
|
|
163
|
+
return false; // #5 nextTurn
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Decode one `key: "<json string>"` header line; undefined when the line is
|
|
167
|
+
* a different key or its scalar is not a JSON string. */
|
|
168
|
+
function readHeaderStringField(line: string, key: string): string | undefined {
|
|
169
|
+
const prefix = `${key}: `;
|
|
170
|
+
if (!line.startsWith(prefix)) return undefined;
|
|
171
|
+
const token = line.slice(prefix.length);
|
|
172
|
+
if (token.length < 2 || token[0] !== '"' || token[token.length - 1] !== '"') return undefined;
|
|
173
|
+
try {
|
|
174
|
+
const value = JSON.parse(token) as unknown;
|
|
175
|
+
return typeof value === 'string' ? value : undefined;
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse an injected content string back into its structural header + body
|
|
183
|
+
* (design D4), decoupled from any TUI so it is unit-testable headlessly.
|
|
184
|
+
* Deliberately narrow: the content must open with a `---` fence whose lines
|
|
185
|
+
* are exactly `from`, `kind: "peer-alignment"`, `id`, optional `ts: <integer>`,
|
|
186
|
+
* optional non-empty `session`, and close on a single `---` line — any step
|
|
187
|
+
* off that template (prose, arbitrary `---`-led Markdown, the plugin's own
|
|
188
|
+
* `/opsx-pipe` notices) returns null, so the renderer falls back to the
|
|
189
|
+
* harness default instead of fabricating a header.
|
|
190
|
+
*/
|
|
191
|
+
export function parsePipeHeader(content: string): PipeHeader | null {
|
|
192
|
+
const lines = content.split('\n');
|
|
193
|
+
if (lines[0] !== '---') return null;
|
|
194
|
+
|
|
195
|
+
const from = readHeaderStringField(lines[1] ?? '', 'from');
|
|
196
|
+
if (from === undefined) return null;
|
|
197
|
+
if (readHeaderStringField(lines[2] ?? '', 'kind') !== 'peer-alignment') return null;
|
|
198
|
+
const id = readHeaderStringField(lines[3] ?? '', 'id');
|
|
199
|
+
if (id === undefined) return null;
|
|
200
|
+
|
|
201
|
+
let cursor = 4;
|
|
202
|
+
let ts: number | undefined;
|
|
203
|
+
const tsLine = lines[cursor] ?? '';
|
|
204
|
+
if (tsLine.startsWith('ts: ')) {
|
|
205
|
+
const token = tsLine.slice('ts: '.length);
|
|
206
|
+
if (!/^-?\d+$/.test(token)) return null;
|
|
207
|
+
ts = Number(token);
|
|
208
|
+
cursor += 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let session: string | undefined;
|
|
212
|
+
const sessionLine = lines[cursor] ?? '';
|
|
213
|
+
if (sessionLine.startsWith('session: ')) {
|
|
214
|
+
session = readHeaderStringField(sessionLine, 'session');
|
|
215
|
+
if (session === undefined || session.length === 0) return null;
|
|
216
|
+
cursor += 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (lines[cursor] !== '---') return null;
|
|
220
|
+
// Everything past the closing fence is the body; rejoining (rather than
|
|
221
|
+
// slicing the source) keeps internal and trailing newlines verbatim.
|
|
222
|
+
const body = lines.slice(cursor + 1).join('\n');
|
|
223
|
+
return {
|
|
224
|
+
from,
|
|
225
|
+
kind: 'peer-alignment',
|
|
226
|
+
id,
|
|
227
|
+
...(ts !== undefined ? { ts } : {}),
|
|
228
|
+
...(session !== undefined ? { session } : {}),
|
|
229
|
+
body,
|
|
230
|
+
};
|
|
120
231
|
}
|
|
121
232
|
|
|
122
233
|
/** Structural validation for a message parsed off disk. Mirrors the minimal
|
|
@@ -180,6 +291,16 @@ interface PushState {
|
|
|
180
291
|
muted: boolean;
|
|
181
292
|
/** Session liveness for the followUp→steer upgrade (see isBusyWaiting). */
|
|
182
293
|
busy: BusyState;
|
|
294
|
+
/**
|
|
295
|
+
* Set by the before_agent_start observer when the upcoming turn will carry
|
|
296
|
+
* the static contract via the per-turn systemPrompt hook.
|
|
297
|
+
*/
|
|
298
|
+
promptContractSeen: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Handed over at turn_start from promptContractSeen: true iff this turn's
|
|
301
|
+
* prompt path confirmed the static contract is in play.
|
|
302
|
+
*/
|
|
303
|
+
turnContractConfirmed: boolean;
|
|
183
304
|
watcher?: WatchHandle;
|
|
184
305
|
/** True once the watcher failed; the 30s tick then carries all scans. */
|
|
185
306
|
degraded: boolean;
|
|
@@ -218,11 +339,17 @@ async function injectMessage(msg: PipeMessage, state: PushState, send: SendFn):
|
|
|
218
339
|
const deliver = normalizeDeliver(msg.deliver);
|
|
219
340
|
const upgraded = deliver.mode === 'followUp' && isBusyWaiting(state.busy);
|
|
220
341
|
const deliverAs: DeliverMode = upgraded ? 'steer' : deliver.mode;
|
|
342
|
+
const contractTail = shouldCarryContractTail({
|
|
343
|
+
inTurn: state.busy.inTurn,
|
|
344
|
+
turnContractConfirmed: state.turnContractConfirmed,
|
|
345
|
+
deliverAs,
|
|
346
|
+
triggerTurn: deliver.triggerTurn,
|
|
347
|
+
});
|
|
221
348
|
await Promise.resolve(
|
|
222
349
|
send(
|
|
223
350
|
{
|
|
224
351
|
customType: PIPE_CUSTOM_TYPE,
|
|
225
|
-
content: injectText(msg),
|
|
352
|
+
content: injectText(msg, { contractTail }),
|
|
226
353
|
display: true,
|
|
227
354
|
attribution: 'agent',
|
|
228
355
|
details: {
|
|
@@ -375,6 +502,8 @@ export async function startPipePush(
|
|
|
375
502
|
root,
|
|
376
503
|
muted: false,
|
|
377
504
|
busy: { inTurn: false, tools: new Map() },
|
|
505
|
+
promptContractSeen: false,
|
|
506
|
+
turnContractConfirmed: false,
|
|
378
507
|
degraded: false,
|
|
379
508
|
seen: new Set(),
|
|
380
509
|
scanChain: Promise.resolve(),
|
|
@@ -499,6 +628,41 @@ export function registerPipePush(pi: ExtensionAPI): void {
|
|
|
499
628
|
},
|
|
500
629
|
});
|
|
501
630
|
|
|
631
|
+
// Human-visible rendering for injected pipe messages (design D4). Only the
|
|
632
|
+
// collapsed, parsable case is customized; everything else returns
|
|
633
|
+
// undefined so the harness's default framed Markdown render takes over —
|
|
634
|
+
// that default is the full-fidelity audit view (all frontmatter fields,
|
|
635
|
+
// as Markdown) and the fallback for non-pipe content sharing this
|
|
636
|
+
// customType (`/opsx-pipe` notices), which must never be rewritten or
|
|
637
|
+
// given a fabricated header.
|
|
638
|
+
pi.registerMessageRenderer(PIPE_CUSTOM_TYPE, (message, { expanded }, theme) => {
|
|
639
|
+
if (expanded === true) return undefined;
|
|
640
|
+
const content = message.content;
|
|
641
|
+
if (typeof content !== 'string') return undefined;
|
|
642
|
+
const header = parsePipeHeader(content);
|
|
643
|
+
if (!header) return undefined;
|
|
644
|
+
|
|
645
|
+
// Strip the optional in-band contract tail for the compact card only;
|
|
646
|
+
// content itself is never rewritten (expanded/audit path keeps it).
|
|
647
|
+
const displayBody = header.body.split(TAIL_MARKER_LINE)[0];
|
|
648
|
+
|
|
649
|
+
const label = `📨 跨 broker 对齐消息 ← ${header.from}${header.session ? ` → ${header.session}` : ''}`;
|
|
650
|
+
const box = new Box(1, 1, (t) => theme.bg('customMessageBg', t));
|
|
651
|
+
// The default custom card opts out of the global tight padding too
|
|
652
|
+
// (modes/components/custom-message.ts) — without this the card's inner
|
|
653
|
+
// padding would shrink 1→0 under `tui.tight` and diverge from it.
|
|
654
|
+
box.setIgnoreTight(true);
|
|
655
|
+
box.setBorder({ chars: theme.boxRound, color: (t) => theme.fg('borderMuted', t) });
|
|
656
|
+
box.addChild(new Text(theme.fg('customMessageLabel', theme.bold(label)), 0, 0));
|
|
657
|
+
box.addChild(new Spacer(1));
|
|
658
|
+
box.addChild(
|
|
659
|
+
new Markdown(displayBody, 0, 0, getMarkdownTheme(), {
|
|
660
|
+
color: (value: string) => theme.fg('customMessageText', value),
|
|
661
|
+
}),
|
|
662
|
+
);
|
|
663
|
+
return box;
|
|
664
|
+
});
|
|
665
|
+
|
|
502
666
|
pi.on('session_start', async (_event, ctx) => {
|
|
503
667
|
try {
|
|
504
668
|
await startPipePush(ctx, (payload, options) => pi.sendMessage(payload, options));
|
|
@@ -514,13 +678,26 @@ export function registerPipePush(pi: ExtensionAPI): void {
|
|
|
514
678
|
}
|
|
515
679
|
});
|
|
516
680
|
|
|
681
|
+
// Contract-presence observer: marks that before_agent_start fired for the
|
|
682
|
+
// upcoming turn (static prompt will be injected). MUST NOT return a value —
|
|
683
|
+
// returning undefined keeps the main handler's systemPrompt chain intact
|
|
684
|
+
// (runner skips falsy handler results).
|
|
685
|
+
pi.on('before_agent_start', (_e, ctx) => {
|
|
686
|
+
const state = stateForCtx(ctx);
|
|
687
|
+
if (state) state.promptContractSeen = true;
|
|
688
|
+
});
|
|
689
|
+
|
|
517
690
|
// Busy-wait tracking: only the owning primary session has PushState, so
|
|
518
691
|
// subagent turn/tool events (different session id) are no-ops. turn_end
|
|
519
692
|
// clears the whole tools map in case a tool_execution_end was missed
|
|
520
|
-
// (e.g. abort mid-batch).
|
|
693
|
+
// (e.g. abort mid-batch). At turn_start, hand over the contract-seen flag
|
|
694
|
+
// so injectMessage can decide whether this turn already carries the static
|
|
695
|
+
// contract (user prompt path) or not (wake/continue bypass).
|
|
521
696
|
pi.on('turn_start', (_event, ctx) => {
|
|
522
697
|
const state = stateForCtx(ctx);
|
|
523
698
|
if (!state) return;
|
|
699
|
+
state.turnContractConfirmed = state.promptContractSeen;
|
|
700
|
+
state.promptContractSeen = false;
|
|
524
701
|
state.busy.inTurn = true;
|
|
525
702
|
});
|
|
526
703
|
pi.on('turn_end', (_event, ctx) => {
|
|
@@ -563,7 +740,11 @@ export function _getStateForTest(brokerId: string): PushState | undefined {
|
|
|
563
740
|
/** Test seam: overwrite busy-wait tracking for a broker (scanOnce upgrade tests). */
|
|
564
741
|
export function _setBusyForTest(
|
|
565
742
|
brokerId: string,
|
|
566
|
-
busy: {
|
|
743
|
+
busy: {
|
|
744
|
+
inTurn: boolean;
|
|
745
|
+
tools?: Array<{ toolCallId: string; toolName: string; args?: unknown }>;
|
|
746
|
+
turnContractConfirmed?: boolean;
|
|
747
|
+
},
|
|
567
748
|
): void {
|
|
568
749
|
const state = states.get(brokerId);
|
|
569
750
|
if (!state) throw new Error(`_setBusyForTest: no state for ${brokerId}`);
|
|
@@ -572,6 +753,9 @@ export function _setBusyForTest(
|
|
|
572
753
|
for (const t of busy.tools ?? []) {
|
|
573
754
|
state.busy.tools.set(t.toolCallId, { toolName: t.toolName, args: t.args });
|
|
574
755
|
}
|
|
756
|
+
if (busy.turnContractConfirmed !== undefined) {
|
|
757
|
+
state.turnContractConfirmed = busy.turnContractConfirmed;
|
|
758
|
+
}
|
|
575
759
|
}
|
|
576
760
|
|
|
577
761
|
/** Test seam: await the settled scan chain (doorbell/tick scans are async). */
|
|
@@ -591,6 +775,8 @@ export async function _makeStateForTest(brokerId: string, cwd: string): Promise<
|
|
|
591
775
|
root,
|
|
592
776
|
muted: false,
|
|
593
777
|
busy: { inTurn: false, tools: new Map() },
|
|
778
|
+
promptContractSeen: false,
|
|
779
|
+
turnContractConfirmed: false,
|
|
594
780
|
degraded: true, // no watcher in this harness
|
|
595
781
|
seen: new Set(),
|
|
596
782
|
scanChain: Promise.resolve(),
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-account regional provider variants (change: china-config, design D3).
|
|
3
|
+
*
|
|
4
|
+
* Some providers expose the same account through two regional entries (e.g.
|
|
5
|
+
* `zai` and `zhipu-coding-plan` are two doors into one Zhipu quota). When both
|
|
6
|
+
* doors are in the candidate pool at once, the same quota competes with itself
|
|
7
|
+
* in scoring and shows up twice in choices. `resolveVariantCollapse` detects
|
|
8
|
+
* that situation and hands back the provider set to drop from the pool — the
|
|
9
|
+
* side kept is driven by the `china` config key's region axis (china=true →
|
|
10
|
+
* keep the domestic entry, false/absent → keep the intl entry).
|
|
11
|
+
*
|
|
12
|
+
* Same-account proof = strict key equality: the collapse fires only when both
|
|
13
|
+
* doors resolve a non-empty API key and the two keys are strictly equal (one
|
|
14
|
+
* account, two regional entries). Vendors legitimately issue several keys per
|
|
15
|
+
* account — one per regional door — so unequal keys keep BOTH doors alive
|
|
16
|
+
* (usage bar renders both rows; the display names 智谱 / Z.AI keep them
|
|
17
|
+
* distinguishable). A missing key or a throwing getter is also conservative:
|
|
18
|
+
* both doors stay. Only both-sides-in-pool gates the trigger; a lone side is
|
|
19
|
+
* left untouched.
|
|
20
|
+
*
|
|
21
|
+
* Everything here is pure: no IO, no logging. The caller pre-resolves the
|
|
22
|
+
* (async) auth lookups into the sync `getApiKey` view.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** One regional-variant row: the domestic and intl entry of the same account. */
|
|
26
|
+
export interface RegionVariantGroup {
|
|
27
|
+
/** Domestic-region entry (kept when `china: true`). */
|
|
28
|
+
domestic: string;
|
|
29
|
+
/** International entry (kept when `china` is false/absent). */
|
|
30
|
+
intl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Same-account regional entry pairs. Single row by design — a generic table
|
|
35
|
+
* is deliberately not built (design D4); extend this constant if a new pair
|
|
36
|
+
* appears (README documents the extension point).
|
|
37
|
+
*/
|
|
38
|
+
export const REGION_VARIANT_GROUPS: readonly RegionVariantGroup[] = [
|
|
39
|
+
{ domestic: 'zhipu-coding-plan', intl: 'zai' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Display names for providers whose id is not user-facing. Unlisted ids render
|
|
44
|
+
* as-is (`providerDisplayName` fallback), so only genuinely ambiguous ids need
|
|
45
|
+
* an entry.
|
|
46
|
+
*/
|
|
47
|
+
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|
48
|
+
zai: 'Z.AI',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Display label for a provider id: mapped name when listed, raw id otherwise. */
|
|
52
|
+
export function providerDisplayName(id: string): string {
|
|
53
|
+
return PROVIDER_DISPLAY_NAMES[id] ?? id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Result of variant resolution: providers to drop + the human-readable pair. */
|
|
57
|
+
export interface VariantCollapseResult {
|
|
58
|
+
/** Providers to exclude from the pool (empty = no collapse). */
|
|
59
|
+
collapsedProviders: Set<string>;
|
|
60
|
+
/** The collapsed (dropped) provider id, or null when not collapsing. */
|
|
61
|
+
dropped: string | null;
|
|
62
|
+
/** The kept provider id, or null when not collapsing. */
|
|
63
|
+
kept: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the same-account regional collapse for a candidate pool.
|
|
68
|
+
*
|
|
69
|
+
* Collapse fires when both entries of a group are in `providersInPool` AND
|
|
70
|
+
* both doors resolve a non-empty API key AND the two keys are strictly equal
|
|
71
|
+
* — key equality is the same-account proof. Unequal keys (vendors issue one
|
|
72
|
+
* key per regional door), a missing key, or a throwing getter are all
|
|
73
|
+
* conservative: both doors stay. The pool-presence check makes
|
|
74
|
+
* allowlist/reachability already having removed one side a no-op. A single
|
|
75
|
+
* side in the pool → empty set / null pair.
|
|
76
|
+
*
|
|
77
|
+
* Direction: `china: true` keeps `domestic`, otherwise (false/absent) keeps
|
|
78
|
+
* `intl`.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveVariantCollapse(opts: {
|
|
81
|
+
china: boolean;
|
|
82
|
+
providersInPool: ReadonlySet<string>;
|
|
83
|
+
/** Sync key view (caller pre-resolves async auth lookups). Absent/throwing/empty keys → no collapse. */
|
|
84
|
+
getApiKey?: (provider: string) => string | undefined;
|
|
85
|
+
}): VariantCollapseResult {
|
|
86
|
+
const idle: VariantCollapseResult = { collapsedProviders: new Set(), dropped: null, kept: null };
|
|
87
|
+
try {
|
|
88
|
+
for (const group of REGION_VARIANT_GROUPS) {
|
|
89
|
+
if (!opts.providersInPool.has(group.domestic) || !opts.providersInPool.has(group.intl)) continue;
|
|
90
|
+
const domesticKey = opts.getApiKey?.(group.domestic);
|
|
91
|
+
const intlKey = opts.getApiKey?.(group.intl);
|
|
92
|
+
if (!domesticKey || !intlKey || domesticKey !== intlKey) continue;
|
|
93
|
+
const dropped = opts.china ? group.intl : group.domestic;
|
|
94
|
+
const kept = opts.china ? group.domestic : group.intl;
|
|
95
|
+
return { collapsedProviders: new Set([dropped]), dropped, kept };
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return idle;
|
|
99
|
+
}
|
|
100
|
+
return idle;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apply an active collapse to the usage bar's row universe: drops the
|
|
105
|
+
* collapsed side's report row, credential placeholder, and active-provider
|
|
106
|
+
* entry so its column never renders. In-pool for usage = usage data or
|
|
107
|
+
* credentials present — model candidates are NOT required (the usage bar has
|
|
108
|
+
* no candidate pool). The kept side already displays the shared quota, so
|
|
109
|
+
* dropping the duplicate door loses no information.
|
|
110
|
+
*/
|
|
111
|
+
export function collapseUsageView<T extends { provider: string }>(
|
|
112
|
+
reports: readonly T[],
|
|
113
|
+
placeholderIds: readonly string[],
|
|
114
|
+
active: readonly string[],
|
|
115
|
+
collapsed: ReadonlySet<string>,
|
|
116
|
+
): { reports: T[]; placeholderIds: string[]; active: string[] } {
|
|
117
|
+
if (collapsed.size === 0) return { reports: [...reports], placeholderIds: [...placeholderIds], active: [...active] };
|
|
118
|
+
return {
|
|
119
|
+
reports: reports.filter((r) => !collapsed.has(r.provider)),
|
|
120
|
+
placeholderIds: placeholderIds.filter((p) => !collapsed.has(p)),
|
|
121
|
+
active: active.filter((p) => !collapsed.has(p)),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One-line choices appendix annotation for an active collapse; null when the
|
|
127
|
+
* collapse is inactive (nothing to annotate — spec: 未激活不标注).
|
|
128
|
+
*/
|
|
129
|
+
export function variantCollapseAnnotation(result: VariantCollapseResult): string | null {
|
|
130
|
+
if (!result.dropped || !result.kept) return null;
|
|
131
|
+
return `**区域变体** ${result.dropped} → 已收起(同账号区域变体,已按区域偏好收起;保留 ${result.kept})`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Allowlist-presence check for the collapse trigger: a provider only counts
|
|
136
|
+
* as "in the pool" when at least one of its models would survive the
|
|
137
|
+
* selection allowlist — otherwise the allowlist has already removed that side
|
|
138
|
+
* and the collapse must not fire (spec: 单变体在池零行为变化). Same glob
|
|
139
|
+
* semantics as the selection layer's private `isAllowlisted`; empty
|
|
140
|
+
* allowlist allows everything.
|
|
141
|
+
*/
|
|
142
|
+
export function providerSurvivesAllowlist(provider: string, modelId: string, allowlist: readonly string[]): boolean {
|
|
143
|
+
if (!allowlist || allowlist.length === 0) return true;
|
|
144
|
+
const selector = `${provider}/${modelId}`;
|
|
145
|
+
return allowlist.some((p) => globToRegex(p).test(selector));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Private copy per the established convention (model-selector / model-tiers /
|
|
149
|
+
// tiers-data each keep an identical private globToRegex; the files are
|
|
150
|
+
// zero-touch for this change).
|
|
151
|
+
function globToRegex(pattern: string): RegExp {
|
|
152
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
153
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
154
|
+
}
|
|
@@ -26,6 +26,7 @@ export function loadAvailableByProvider(ttlMs: number): Map<string, string[] | n
|
|
|
26
26
|
export async function refreshReachability(
|
|
27
27
|
models: Model<Api>[] | null | undefined,
|
|
28
28
|
config: Pick<ResolvedOpsxConfig, 'probe_enabled' | 'probe_timeout_ms' | 'probe_ttl_ms' | 'excluded_providers'>,
|
|
29
|
+
opts?: { reprobeUnreachable?: boolean },
|
|
29
30
|
): Promise<{ unreachable: Set<string>; availableByProvider: Map<string, string[] | null> }> {
|
|
30
31
|
const ttl = config.probe_ttl_ms;
|
|
31
32
|
const excluded = new Set(config.excluded_providers);
|
|
@@ -39,8 +40,11 @@ export async function refreshReachability(
|
|
|
39
40
|
const endpoints = extractEndpoints(models);
|
|
40
41
|
for (const p of excluded) endpoints.delete(p);
|
|
41
42
|
const fresh = getFreshlyProbedProviders(ttl);
|
|
43
|
+
const reprobeUnreachable = opts?.reprobeUnreachable === true;
|
|
42
44
|
for (const p of [...endpoints.keys()]) {
|
|
43
|
-
|
|
45
|
+
// TTL-fresh entries skip re-probe, except unreachable ones when the
|
|
46
|
+
// autoselect-retry path asks to refresh poisoned negatives only.
|
|
47
|
+
if (fresh.has(p) && !(reprobeUnreachable && unreachable.has(p))) endpoints.delete(p);
|
|
44
48
|
}
|
|
45
49
|
if (endpoints.size > 0) {
|
|
46
50
|
const tls = await probeProviders(endpoints, { timeoutMs: config.probe_timeout_ms });
|
|
@@ -55,7 +59,11 @@ export async function refreshReachability(
|
|
|
55
59
|
// Cursor / OpenCode Go stay in the pool even if a 3s TLS probe
|
|
56
60
|
// of the public host fails — quota fetch and discovery are the
|
|
57
61
|
// real signals. TLS exclusion is for official Anthropic/OpenAI etc.
|
|
58
|
-
|
|
62
|
+
// On recovery (reprobeUnreachable path), drop the prior poison
|
|
63
|
+
// so the returned set matches the freshly merged cache.
|
|
64
|
+
if (result.reachable) {
|
|
65
|
+
unreachable.delete(provider);
|
|
66
|
+
} else if (!(DISCOVERY_PROVIDERS as readonly string[]).includes(provider)) {
|
|
59
67
|
unreachable.add(provider);
|
|
60
68
|
}
|
|
61
69
|
}
|
package/lib/selection-filters.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
2
2
|
import { matchesSelector, matchesTokenPrefix } from './family-filter.js';
|
|
3
|
+
import { canonicalizeProvider } from './usage-resolver.js';
|
|
3
4
|
|
|
4
5
|
/** Case-insensitive model-id membership for GetUsableModels / /v1/models lists. */
|
|
5
6
|
export function idInAvailableList(modelId: string, available: readonly string[]): boolean {
|
|
@@ -30,6 +31,19 @@ export function filterSelectableModels<T extends { provider: string; id: string
|
|
|
30
31
|
* The only caller is the `--china` CHINA_EXCLUDE_FAMILIES gateway.
|
|
31
32
|
*/
|
|
32
33
|
excludeSelectors?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Per-provider model patterns (provider-auto-targets): a listed provider is
|
|
36
|
+
* narrowed to models matching any pattern; unlisted providers are untouched.
|
|
37
|
+
* Keys MUST already be canonical (`canonicalizeProvider` at the parse layer).
|
|
38
|
+
* Patterns without `/` are evaluated as a two-segment `*` / `<pattern>`
|
|
39
|
+
* form (id-side full-string anchored glob — a bare word must NOT fall
|
|
40
|
+
* through to token-prefix OR, or
|
|
41
|
+
* `deepseek-flash` would overmatch `deepseek-v4-flash`); patterns containing
|
|
42
|
+
* `/` pass through the three-level grammar verbatim.
|
|
43
|
+
*/
|
|
44
|
+
providerModels?: Map<string, readonly string[]>;
|
|
45
|
+
/** Out-param: canonical provider keys whose narrowing kept 0 of >0 pre-predicate candidates (mirrors discoveryWipes; upstream-excluded providers never land here). */
|
|
46
|
+
zeroHitProviders?: Set<string>;
|
|
33
47
|
},
|
|
34
48
|
): T[] {
|
|
35
49
|
const blocked = opts.blockedSelectors ?? new Set();
|
|
@@ -38,6 +52,13 @@ export function filterSelectableModels<T extends { provider: string; id: string
|
|
|
38
52
|
const available = opts.availableByProvider;
|
|
39
53
|
const selectors = opts.selectors;
|
|
40
54
|
const excludeSelectors = opts.excludeSelectors;
|
|
55
|
+
const providerModels = opts.providerModels;
|
|
56
|
+
// Narrowing diagnostics: count per canonical key only candidates that
|
|
57
|
+
// passed every earlier predicate, so a provider wiped by exclusion /
|
|
58
|
+
// reachability is never reported as a provider_models misconfiguration.
|
|
59
|
+
const pmTotal = new Map<string, number>();
|
|
60
|
+
const pmKept = new Map<string, number>();
|
|
61
|
+
const canonCache = new Map<string, string>();
|
|
41
62
|
|
|
42
63
|
const discoveryWipes = new Set<string>();
|
|
43
64
|
if (available) {
|
|
@@ -56,7 +77,7 @@ export function filterSelectableModels<T extends { provider: string; id: string
|
|
|
56
77
|
}
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
|
|
80
|
+
const filtered = models.filter((m) => {
|
|
60
81
|
if (excluded.has(m.provider) || unreachable.has(m.provider)) return false;
|
|
61
82
|
if (blocked.has(`${m.provider}/${m.id}`)) return false;
|
|
62
83
|
const list = available?.get(m.provider);
|
|
@@ -70,8 +91,28 @@ export function filterSelectableModels<T extends { provider: string; id: string
|
|
|
70
91
|
if (excludeSelectors && excludeSelectors.some((f) => matchesTokenPrefix(m.provider, m.id, f))) {
|
|
71
92
|
return false;
|
|
72
93
|
}
|
|
94
|
+
if (providerModels) {
|
|
95
|
+
let canon = canonCache.get(m.provider);
|
|
96
|
+
if (canon === undefined) {
|
|
97
|
+
canon = canonicalizeProvider(m.provider);
|
|
98
|
+
canonCache.set(m.provider, canon);
|
|
99
|
+
}
|
|
100
|
+
const patterns = canon ? providerModels.get(canon) : undefined;
|
|
101
|
+
if (patterns && patterns.length > 0) {
|
|
102
|
+
pmTotal.set(canon, (pmTotal.get(canon) ?? 0) + 1);
|
|
103
|
+
const hit = patterns.some((p) => matchesSelector(m.provider, m.id, p.includes('/') ? p : `*/${p}`));
|
|
104
|
+
if (!hit) return false;
|
|
105
|
+
pmKept.set(canon, (pmKept.get(canon) ?? 0) + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
73
108
|
return true;
|
|
74
109
|
});
|
|
110
|
+
if (providerModels && opts.zeroHitProviders) {
|
|
111
|
+
for (const [canon, total] of pmTotal) {
|
|
112
|
+
if (total > 0 && (pmKept.get(canon) ?? 0) === 0) opts.zeroHitProviders.add(canon);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return filtered;
|
|
75
116
|
}
|
|
76
117
|
|
|
77
118
|
export function collectAvailableByProvider(
|