@genee/omp-opsx-addon 0.10.0 → 0.12.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 +4 -2
- package/index.ts +24 -15
- package/lib/concurrency-control.ts +6 -6
- package/lib/concurrency-writer.ts +25 -13
- package/lib/cursor-auth.ts +34 -18
- package/lib/cursor-limit-status.ts +250 -0
- package/lib/edit-variant-pin.ts +34 -26
- package/lib/host-settings-adapter.ts +227 -0
- package/lib/model-roles.ts +41 -12
- package/lib/model-selector.ts +5 -1
- package/lib/usage-poller.ts +10 -1
- package/lib/usage-render.ts +29 -0
- package/lib/usage-resolver.ts +22 -1
- package/package.json +1 -1
- package/types/host-settings-modules.d.ts +48 -0
package/README.md
CHANGED
|
@@ -458,8 +458,10 @@ quality_preference: balanced # balanced(缺省,逐字节现状)| cost |
|
|
|
458
458
|
- top 补选没有可用模型(候选耗尽/allowlist 全过滤)时,slow 不写覆盖、warn 汇总
|
|
459
459
|
(`no-pick`),命令不中断;无任何模型时计划为空,与既有「模型注册表不可用」分支一致。
|
|
460
460
|
- 覆盖为 **session 作用域、不写盘**:`overrideModelRoles` 只写 runtime overlay,
|
|
461
|
-
session 结束即还原;不会修改你的 `~/.omp/agent/config.yml
|
|
462
|
-
|
|
461
|
+
session 结束即还原;不会修改你的 `~/.omp/agent/config.yml`。每次应用前先清除本 session
|
|
462
|
+
的 modelRoles 运行时覆盖(宿主 18.3+ 走注册表句柄 `clearOverrideValue(cfgModelRoles)`,
|
|
463
|
+
≤18.1 走路径式 `clearOverride('modelRoles')`,两者由 `lib/host-settings-adapter.ts`
|
|
464
|
+
统一选取),避免上一 selector 的旧 role 值残留;`reset` 重算无约束
|
|
463
465
|
默认并覆盖(**不清空**,否则回落 config 硬钉)。
|
|
464
466
|
|
|
465
467
|
> **手动清理建议**:若全局 `~/.omp/agent/config.yml` 里有 `modelRoles.smol` 钉在会耗尽的
|
package/index.ts
CHANGED
|
@@ -843,7 +843,7 @@ export interface ConcurrencyAssemblyDeps {
|
|
|
843
843
|
configPath: string;
|
|
844
844
|
/** Agent dir the log reader / `model_perf` path derive from. */
|
|
845
845
|
agentDir?: string;
|
|
846
|
-
/** Host settings
|
|
846
|
+
/** Host settings singleton for the hot-apply (API generation resolved inside the adapter). */
|
|
847
847
|
settings?: SettingsPort | null;
|
|
848
848
|
/** Cross-process ① aggregation channel (`null` → local-only + warn). */
|
|
849
849
|
redis?: ConcurrencyRedisPort | null;
|
|
@@ -910,11 +910,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
910
910
|
// but the model repeatedly breaks sloppy control lines too — retry loops
|
|
911
911
|
// burn tokens and abort agents. Point it at the simplest old_string/
|
|
912
912
|
// new_string "replace" variant instead.
|
|
913
|
-
// Preferred path:
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
913
|
+
// Preferred path: the runtime override (registry handle on omp >= 18.3,
|
|
914
|
+
// `settings.override` before that), in-memory and per-session. omp 18.0.11
|
|
915
|
+
// dropped `edit.modelVariants` from the path-based settings schema, so on
|
|
916
|
+
// hosts where neither runtime API reaches it the pin is persisted into the
|
|
917
|
+
// global config.yml once — the host reads the merged view directly, making
|
|
918
|
+
// the file equivalent. Fire-and-forget: the pin applies from the first
|
|
919
|
+
// edit turn, nothing here depends on its completion, and the function
|
|
920
|
+
// never rejects (it warns through the host logger).
|
|
921
|
+
void applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
|
|
918
922
|
const directory = process.cwd();
|
|
919
923
|
const warn = (msg: string) => pi.logger.warn(msg);
|
|
920
924
|
const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
|
|
@@ -1155,8 +1159,8 @@ export default (pi: ExtensionAPI) => {
|
|
|
1155
1159
|
// copy in node_modules is a separate, uninitialized instance — writing to
|
|
1156
1160
|
// it was a no-op). This thin wrapper is the single call site the
|
|
1157
1161
|
// constrained-period tick-reselect path (auto-model-family) also uses.
|
|
1158
|
-
const applyRoleModel = (sel: SelectionContext): void => {
|
|
1159
|
-
applyRoleModelOverrides(pi.pi?.settings, sel, warn);
|
|
1162
|
+
const applyRoleModel = async (sel: SelectionContext): Promise<void> => {
|
|
1163
|
+
await applyRoleModelOverrides(pi.pi?.settings, sel, warn);
|
|
1160
1164
|
};
|
|
1161
1165
|
|
|
1162
1166
|
// Instance-level usage-record claim: true only while this instance's
|
|
@@ -1170,10 +1174,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
1170
1174
|
let recordsOwnUsage = false;
|
|
1171
1175
|
|
|
1172
1176
|
// ── adaptive provider concurrency: host seams ─────────────────
|
|
1173
|
-
/**
|
|
1177
|
+
/**
|
|
1178
|
+
* Host settings port for the control loop's hot-apply. The API shape is the
|
|
1179
|
+
* adapter's business (`lib/host-settings-adapter.ts`: registry handles on
|
|
1180
|
+
* omp >= 18.3, path-based `override` before that), so any object — i.e.
|
|
1181
|
+
* every host generation — qualifies here.
|
|
1182
|
+
*/
|
|
1174
1183
|
const hostSettingsPort = (): SettingsPort | null => {
|
|
1175
|
-
const settings = pi.pi?.settings as unknown
|
|
1176
|
-
return settings && typeof settings
|
|
1184
|
+
const settings = pi.pi?.settings as unknown;
|
|
1185
|
+
return settings && typeof settings === 'object' ? (settings as SettingsPort) : null;
|
|
1177
1186
|
};
|
|
1178
1187
|
/** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
|
|
1179
1188
|
const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
|
|
@@ -1749,7 +1758,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1749
1758
|
false,
|
|
1750
1759
|
reprobeUnreachable ? { reprobeUnreachable: true } : undefined,
|
|
1751
1760
|
);
|
|
1752
|
-
applyRoleModel(sel);
|
|
1761
|
+
await applyRoleModel(sel);
|
|
1753
1762
|
const picked = sel.selectorResults.reviewer?.picked;
|
|
1754
1763
|
if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
|
|
1755
1764
|
await pi.setModel(picked);
|
|
@@ -1995,7 +2004,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1995
2004
|
}
|
|
1996
2005
|
}
|
|
1997
2006
|
selection = freshSel;
|
|
1998
|
-
applyRoleModel(freshSel);
|
|
2007
|
+
await applyRoleModel(freshSel);
|
|
1999
2008
|
const content = changes.length > 0
|
|
2000
2009
|
? renderPickModelReport('refresh', { action: '已刷新', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
|
|
2001
2010
|
: renderPickModelReport('refresh', { action: '已刷新 · 所有模型状态正常,无需切换' }, symbols);
|
|
@@ -2062,7 +2071,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2062
2071
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
2063
2072
|
selection = sel;
|
|
2064
2073
|
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
2065
|
-
applyRoleModel(sel);
|
|
2074
|
+
await applyRoleModel(sel);
|
|
2066
2075
|
pi.sendMessage({
|
|
2067
2076
|
customType: 'pick-model',
|
|
2068
2077
|
content: renderPickModelReport('reset', {
|
|
@@ -2235,7 +2244,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2235
2244
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
2236
2245
|
selection = sel;
|
|
2237
2246
|
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
2238
|
-
applyRoleModel(sel);
|
|
2247
|
+
await applyRoleModel(sel);
|
|
2239
2248
|
const content = renderPickModelReport('switch', {
|
|
2240
2249
|
action: '已切换 selector 约束',
|
|
2241
2250
|
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
@@ -471,7 +471,7 @@ export interface ConcurrencyControlOptions {
|
|
|
471
471
|
dbPath?: string;
|
|
472
472
|
/** Cross-process ① aggregation channel; `null`/omitted disables it (warn + local only). */
|
|
473
473
|
redis?: ConcurrencyRedisPort | null;
|
|
474
|
-
/**
|
|
474
|
+
/** Host settings singleton for the hot-apply seam; omitted → file write only. */
|
|
475
475
|
settings?: SettingsPort | null;
|
|
476
476
|
/** Per-provider ceiling overrides (`concurrency_ceiling_by_provider`). */
|
|
477
477
|
ceilingByProvider?: Record<string, number>;
|
|
@@ -868,7 +868,7 @@ export class ConcurrencyController {
|
|
|
868
868
|
const shared = sharedMap[provider];
|
|
869
869
|
const divergedFromShared = typeof shared === 'number' && shared !== next.limit;
|
|
870
870
|
if ((evaluation.increased || evaluation.decreased || decay.moved || divergedFromShared) && isWriter) {
|
|
871
|
-
view.written = this.persistLimit(provider, next.limit, sharedMap, now);
|
|
871
|
+
view.written = await this.persistLimit(provider, next.limit, sharedMap, now);
|
|
872
872
|
}
|
|
873
873
|
result.providers[provider] = view;
|
|
874
874
|
}
|
|
@@ -1145,12 +1145,12 @@ export class ConcurrencyController {
|
|
|
1145
1145
|
return Math.max(1, Math.ceil((interval * AGGREGATION_TTL_INTERVALS) / 1000));
|
|
1146
1146
|
}
|
|
1147
1147
|
|
|
1148
|
-
private persistLimit(
|
|
1148
|
+
private async persistLimit(
|
|
1149
1149
|
provider: string,
|
|
1150
1150
|
limit: number,
|
|
1151
1151
|
sharedMap: Record<string, number>,
|
|
1152
1152
|
now: number,
|
|
1153
|
-
): boolean {
|
|
1153
|
+
): Promise<boolean> {
|
|
1154
1154
|
// Write gate (task 4.2): |Δ| >= 1 AND more than
|
|
1155
1155
|
// concurrency_write_min_interval_ms since this provider's last write.
|
|
1156
1156
|
const gate = shouldWriteLimit({
|
|
@@ -1179,7 +1179,7 @@ export class ConcurrencyController {
|
|
|
1179
1179
|
return false;
|
|
1180
1180
|
}
|
|
1181
1181
|
// Adopt the in-lock read: it is the freshest view of every provider, so
|
|
1182
|
-
// the rest of this tick and the whole-key
|
|
1182
|
+
// the rest of this tick and the whole-key hot-apply override below never
|
|
1183
1183
|
// carry a snapshot taken before another process's write landed.
|
|
1184
1184
|
if (outcome.limits) {
|
|
1185
1185
|
for (const [id, value] of Object.entries(outcome.limits)) sharedMap[id] = value;
|
|
@@ -1188,7 +1188,7 @@ export class ConcurrencyController {
|
|
|
1188
1188
|
this.lastWriteAt.set(provider, now);
|
|
1189
1189
|
if (this.settings) {
|
|
1190
1190
|
try {
|
|
1191
|
-
const applied = applyLimit({
|
|
1191
|
+
const applied = await applyLimit({
|
|
1192
1192
|
settings: this.settings,
|
|
1193
1193
|
merged: outcome.limits ?? merged,
|
|
1194
1194
|
provider,
|
|
@@ -38,6 +38,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statS
|
|
|
38
38
|
import { dirname } from 'path';
|
|
39
39
|
import { YAML } from 'bun';
|
|
40
40
|
import { clampLimit } from './concurrency-tuner.js';
|
|
41
|
+
import { overrideProviderMaxInFlight } from './host-settings-adapter.js';
|
|
41
42
|
import { canonicalizeProvider } from './usage-resolver.js';
|
|
42
43
|
|
|
43
44
|
/** Sink for degradation notices; defaults to a no-op. */
|
|
@@ -218,8 +219,8 @@ export function readManagedLimits(
|
|
|
218
219
|
/**
|
|
219
220
|
* Merge one provider's new limit into the **complete** per-provider map.
|
|
220
221
|
* `providers.maxInFlightRequests` is a whole-key override on the host side, so
|
|
221
|
-
* every caller MUST pass the full map (this is the map that goes to
|
|
222
|
-
*
|
|
222
|
+
* every caller MUST pass the full map (this is the map that goes to the
|
|
223
|
+
* hot-apply override — {@link applyLimit} — and to {@link writeManagedLimit}).
|
|
223
224
|
*/
|
|
224
225
|
export function buildMergedMap(
|
|
225
226
|
current: Record<string, number>,
|
|
@@ -582,10 +583,15 @@ export function writeManagedLimit(args: WriteManagedLimitArgs): WriteManagedLimi
|
|
|
582
583
|
}
|
|
583
584
|
|
|
584
585
|
/**
|
|
585
|
-
* Injected host settings port (`pi.pi.settings`)
|
|
586
|
+
* Injected host settings port (`pi.pi.settings`).
|
|
587
|
+
*
|
|
588
|
+
* Only the identity of the host singleton matters here: the hot-apply goes
|
|
589
|
+
* through `lib/host-settings-adapter.ts`, which resolves the API generation
|
|
590
|
+
* (registry handle on omp >= 18.3, path-based `override` before that). Hence
|
|
591
|
+
* `override` is optional — a host that dropped it is still a usable port.
|
|
586
592
|
*/
|
|
587
593
|
export interface SettingsPort {
|
|
588
|
-
override(path: string, value: unknown): void;
|
|
594
|
+
override?(path: string, value: unknown): void;
|
|
589
595
|
}
|
|
590
596
|
|
|
591
597
|
export interface ApplyLimitArgs {
|
|
@@ -604,8 +610,10 @@ export interface ApplyLimitResult {
|
|
|
604
610
|
}
|
|
605
611
|
|
|
606
612
|
/**
|
|
607
|
-
* Hot-apply the shared map in this process
|
|
608
|
-
*
|
|
613
|
+
* Hot-apply the shared map in this process through the host settings runtime
|
|
614
|
+
* override (`providers.maxInFlightRequests`: registry handle on omp >= 18.3,
|
|
615
|
+
* path-based `settings.override` before that — both via
|
|
616
|
+
* `lib/host-settings-adapter.ts`).
|
|
609
617
|
*
|
|
610
618
|
* `providers.maxInFlightRequests` is a **whole-key override**, so the second
|
|
611
619
|
* argument MUST be the complete per-provider map — passing only the target
|
|
@@ -614,13 +622,14 @@ export interface ApplyLimitResult {
|
|
|
614
622
|
* (the caller keeps the previous value), while a partial map would silently
|
|
615
623
|
* make the omitted providers unlimited. When the API throws (host schema moved
|
|
616
624
|
* on), the persisted config is untouched — the host config watcher picks the
|
|
617
|
-
* value up — and the failure is warned, never fatal.
|
|
625
|
+
* value up — and the failure is warned, never fatal. Async because the adapter
|
|
626
|
+
* loads host handles by dynamic import.
|
|
618
627
|
*/
|
|
619
|
-
export function applyLimit(args: ApplyLimitArgs): ApplyLimitResult {
|
|
628
|
+
export async function applyLimit(args: ApplyLimitArgs): Promise<ApplyLimitResult> {
|
|
620
629
|
const warn = args.warn ?? NO_WARN;
|
|
621
630
|
const settings = args.settings;
|
|
622
|
-
if (!settings
|
|
623
|
-
warn('[omp-opsx-addon] concurrency: settings
|
|
631
|
+
if (!settings) {
|
|
632
|
+
warn('[omp-opsx-addon] concurrency: settings unavailable; skipping hot apply (config file value still applies)');
|
|
624
633
|
return { applied: false, reason: 'settings-unavailable' };
|
|
625
634
|
}
|
|
626
635
|
const provider = canonicalizeProvider(args.provider.trim());
|
|
@@ -631,15 +640,18 @@ export function applyLimit(args: ApplyLimitArgs): ApplyLimitResult {
|
|
|
631
640
|
const payload = buildMergedMap(args.merged ?? {}, provider, args.value);
|
|
632
641
|
for (const [id, value] of Object.entries(payload)) {
|
|
633
642
|
if (!id || typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
634
|
-
warn(`[omp-opsx-addon] concurrency: refusing
|
|
643
|
+
warn(`[omp-opsx-addon] concurrency: refusing the hot-apply override — invalid map entry ${JSON.stringify(id)}: ${JSON.stringify(value)}`);
|
|
635
644
|
return { applied: false, reason: 'invalid-merged' };
|
|
636
645
|
}
|
|
637
646
|
}
|
|
638
647
|
try {
|
|
639
|
-
settings
|
|
648
|
+
if (!(await overrideProviderMaxInFlight(settings, payload))) {
|
|
649
|
+
warn('[omp-opsx-addon] concurrency: no settings runtime-override API available (host handle / settings.override); skipping hot apply (config file value still applies)');
|
|
650
|
+
return { applied: false, reason: 'settings-unavailable' };
|
|
651
|
+
}
|
|
640
652
|
return { applied: true };
|
|
641
653
|
} catch (err) {
|
|
642
|
-
warn(`[omp-opsx-addon] concurrency: settings
|
|
654
|
+
warn(`[omp-opsx-addon] concurrency: settings hot-apply failed (${message(err)}); the host config watcher will apply the persisted value`);
|
|
643
655
|
return { applied: false, reason: 'override-failed' };
|
|
644
656
|
}
|
|
645
657
|
}
|
package/lib/cursor-auth.ts
CHANGED
|
@@ -84,30 +84,46 @@ export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean
|
|
|
84
84
|
return exp * 1000 <= nowMs;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function queryTokensFromDb(db: Database): CursorTokenStore | null {
|
|
88
|
+
const row = (key: string): string | undefined => {
|
|
89
|
+
const r = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
|
90
|
+
| { value?: unknown }
|
|
91
|
+
| null
|
|
92
|
+
| undefined;
|
|
93
|
+
if (!r || r.value == null) return undefined;
|
|
94
|
+
const v = r.value;
|
|
95
|
+
return typeof v === 'string' ? v : Buffer.isBuffer(v) ? v.toString('utf8') : String(v);
|
|
96
|
+
};
|
|
97
|
+
const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
|
|
98
|
+
const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
|
|
99
|
+
const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
|
|
100
|
+
if (!accessToken && !refreshToken) return null;
|
|
101
|
+
return { accessToken, refreshToken, membershipType };
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function defaultReadTokensFromDb(dbPath: string): CursorTokenStore | null {
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
// Read-only first. Bun's bundled SQLite defers the open to the first
|
|
106
|
+
// prepare, where a live Cursor DB (active WAL/locks on APFS) can raise
|
|
107
|
+
// SQLITE_CANTOPEN even though the file is readable — plain python3 and a
|
|
108
|
+
// default read-write handle both work. So a failed readonly pass falls
|
|
109
|
+
// back to the default open; the SHARED lock is only held for the three
|
|
110
|
+
// key reads before we close, same footprint as any external reader.
|
|
111
|
+
for (const opts of [{ readonly: true } as const, undefined]) {
|
|
112
|
+
let db: Database;
|
|
90
113
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
};
|
|
100
|
-
const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
|
|
101
|
-
const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
|
|
102
|
-
const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
|
|
103
|
-
if (!accessToken && !refreshToken) return null;
|
|
104
|
-
return { accessToken, refreshToken, membershipType };
|
|
114
|
+
db = new Database(dbPath, opts);
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return queryTokensFromDb(db);
|
|
120
|
+
} catch {
|
|
121
|
+
// Try the next open mode.
|
|
105
122
|
} finally {
|
|
106
123
|
db.close();
|
|
107
124
|
}
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
125
|
}
|
|
126
|
+
return null;
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
function readStore(env: NodeJS.ProcessEnv = process.env): CursorTokenStore {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor usage-limit / slow-pool status via Connect RPC
|
|
3
|
+
* (`GetUsageLimitStatusAndActiveGrants`), plus note encode/decode for
|
|
4
|
+
* attaching the parsed status to a `UsageReport` without extending pi-ai types.
|
|
5
|
+
*
|
|
6
|
+
* Fail-soft everywhere: field drift / network errors / missing token → null /
|
|
7
|
+
* undefined, never throw. Module-level TTL cache absorbs intermittent RPC
|
|
8
|
+
* failures so the widget does not flap exhausted↔warning.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
12
|
+
import { resolveCursorAccessToken } from './cursor-auth.js';
|
|
13
|
+
|
|
14
|
+
export const CURSOR_LIMIT_STATUS_URL =
|
|
15
|
+
'https://api2.cursor.sh/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants';
|
|
16
|
+
|
|
17
|
+
export const LIMIT_STATUS_TTL_MS = 5 * 60_000;
|
|
18
|
+
export const LIMIT_STATUS_TIMEOUT_MS = 5000;
|
|
19
|
+
|
|
20
|
+
export const CURSOR_LIMIT_STATUS_NOTE_PREFIX = 'cursorLimitStatus=';
|
|
21
|
+
|
|
22
|
+
export interface CursorLimitStatus {
|
|
23
|
+
isInSlowPool: boolean;
|
|
24
|
+
stage?: string;
|
|
25
|
+
slownessMs?: number;
|
|
26
|
+
grantsRemainingCents: number;
|
|
27
|
+
grantsExpiresAtMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FetchCursorLimitStatusOpts {
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
|
|
34
|
+
nowMs?: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type CacheEntry = { status: CursorLimitStatus; atMs: number };
|
|
38
|
+
|
|
39
|
+
let cache: CacheEntry | null = null;
|
|
40
|
+
|
|
41
|
+
/** Module-level seam defaults (opts override these). Cleared by `_resetLimitStatusCacheForTest`. */
|
|
42
|
+
let seamFetchImpl: typeof fetch | undefined;
|
|
43
|
+
let seamResolveCursorToken: (() => Promise<{ accessToken: string } | null>) | undefined;
|
|
44
|
+
let seamNowMs: (() => number) | undefined;
|
|
45
|
+
|
|
46
|
+
export function _setLimitStatusSeamsForTest(seams: {
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
|
|
49
|
+
nowMs?: () => number;
|
|
50
|
+
} | null): void {
|
|
51
|
+
if (!seams) {
|
|
52
|
+
seamFetchImpl = undefined;
|
|
53
|
+
seamResolveCursorToken = undefined;
|
|
54
|
+
seamNowMs = undefined;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if ('fetchImpl' in seams) seamFetchImpl = seams.fetchImpl;
|
|
58
|
+
if ('resolveCursorToken' in seams) seamResolveCursorToken = seams.resolveCursorToken;
|
|
59
|
+
if ('nowMs' in seams) seamNowMs = seams.nowMs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function _resetLimitStatusCacheForTest(): void {
|
|
63
|
+
cache = null;
|
|
64
|
+
seamFetchImpl = undefined;
|
|
65
|
+
seamResolveCursorToken = undefined;
|
|
66
|
+
seamNowMs = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Defensive parse of the Connect JSON body. Returns null on any structural
|
|
71
|
+
* drift (missing policy status, non-boolean isInSlowPool, non-object body).
|
|
72
|
+
* Invalid grant entries are skipped; cents are summed from valid ones.
|
|
73
|
+
*/
|
|
74
|
+
export function parseCursorLimitStatusResponse(data: unknown): CursorLimitStatus | null {
|
|
75
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
76
|
+
const root = data as Record<string, unknown>;
|
|
77
|
+
const policy = root.usageLimitPolicyStatus;
|
|
78
|
+
if (policy === null || typeof policy !== 'object' || Array.isArray(policy)) return null;
|
|
79
|
+
const pol = policy as Record<string, unknown>;
|
|
80
|
+
if (typeof pol.isInSlowPool !== 'boolean') return null;
|
|
81
|
+
|
|
82
|
+
const stage = typeof pol.stage === 'string' ? pol.stage : undefined;
|
|
83
|
+
const slownessMs =
|
|
84
|
+
typeof pol.slownessMs === 'number' && Number.isFinite(pol.slownessMs) ? pol.slownessMs : undefined;
|
|
85
|
+
|
|
86
|
+
let grantsRemainingCents = 0;
|
|
87
|
+
let grantsExpiresAtMs: number | undefined;
|
|
88
|
+
const grants = root.activeGrants;
|
|
89
|
+
if (Array.isArray(grants)) {
|
|
90
|
+
for (const entry of grants) {
|
|
91
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
92
|
+
const g = entry as Record<string, unknown>;
|
|
93
|
+
const raw = g.remainingCents;
|
|
94
|
+
if (typeof raw !== 'string') continue;
|
|
95
|
+
// Integer cents string only — reject floats / signs / whitespace-padded junk.
|
|
96
|
+
if (!/^\d+$/.test(raw)) continue;
|
|
97
|
+
const cents = Number(raw);
|
|
98
|
+
if (!Number.isSafeInteger(cents) || cents < 0) continue;
|
|
99
|
+
grantsRemainingCents += cents;
|
|
100
|
+
if (cents > 0) {
|
|
101
|
+
const expRaw = g.expiresAtMs;
|
|
102
|
+
const exp =
|
|
103
|
+
typeof expRaw === 'string' && /^\d+$/.test(expRaw)
|
|
104
|
+
? Number(expRaw)
|
|
105
|
+
: typeof expRaw === 'number'
|
|
106
|
+
? expRaw
|
|
107
|
+
: NaN;
|
|
108
|
+
if (Number.isFinite(exp) && Number.isSafeInteger(exp)) {
|
|
109
|
+
grantsExpiresAtMs =
|
|
110
|
+
grantsExpiresAtMs === undefined ? exp : Math.max(grantsExpiresAtMs, exp);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else if (grants !== undefined) {
|
|
115
|
+
// Present but not an array → drift; treat whole parse as unknown.
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const out: CursorLimitStatus = {
|
|
120
|
+
isInSlowPool: pol.isInSlowPool,
|
|
121
|
+
grantsRemainingCents,
|
|
122
|
+
};
|
|
123
|
+
if (stage !== undefined) out.stage = stage;
|
|
124
|
+
if (slownessMs !== undefined) out.slownessMs = slownessMs;
|
|
125
|
+
if (grantsExpiresAtMs !== undefined) out.grantsExpiresAtMs = grantsExpiresAtMs;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Encode status into the structured note (omit absent optional fields). */
|
|
130
|
+
export function encodeCursorLimitStatusNote(status: CursorLimitStatus): string {
|
|
131
|
+
const parts: string[] = [`isInSlowPool:${status.isInSlowPool ? 1 : 0}`];
|
|
132
|
+
if (status.stage !== undefined) parts.push(`stage:${status.stage}`);
|
|
133
|
+
if (status.slownessMs !== undefined) parts.push(`slownessMs:${status.slownessMs}`);
|
|
134
|
+
parts.push(`grantsCents:${status.grantsRemainingCents}`);
|
|
135
|
+
if (status.grantsExpiresAtMs !== undefined) parts.push(`grantsExpiryMs:${status.grantsExpiresAtMs}`);
|
|
136
|
+
return CURSOR_LIMIT_STATUS_NOTE_PREFIX + parts.join(';');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Tolerant note parse: unknown fields ignored, truncated/malformed note →
|
|
141
|
+
* undefined (never throws). Requires at least a recognizable isInSlowPool bit.
|
|
142
|
+
*/
|
|
143
|
+
export function parseCursorLimitStatusNote(notes?: string[]): CursorLimitStatus | undefined {
|
|
144
|
+
if (!notes) return undefined;
|
|
145
|
+
for (const n of notes) {
|
|
146
|
+
if (!n.startsWith(CURSOR_LIMIT_STATUS_NOTE_PREFIX)) continue;
|
|
147
|
+
const body = n.slice(CURSOR_LIMIT_STATUS_NOTE_PREFIX.length);
|
|
148
|
+
if (!body) return undefined;
|
|
149
|
+
const fields = new Map<string, string>();
|
|
150
|
+
for (const part of body.split(';')) {
|
|
151
|
+
if (!part) continue;
|
|
152
|
+
const colon = part.indexOf(':');
|
|
153
|
+
if (colon <= 0) continue;
|
|
154
|
+
fields.set(part.slice(0, colon), part.slice(colon + 1));
|
|
155
|
+
}
|
|
156
|
+
const slowRaw = fields.get('isInSlowPool');
|
|
157
|
+
if (slowRaw !== '0' && slowRaw !== '1') return undefined;
|
|
158
|
+
const grantsRaw = fields.get('grantsCents');
|
|
159
|
+
let grantsRemainingCents = 0;
|
|
160
|
+
if (grantsRaw !== undefined) {
|
|
161
|
+
if (!/^\d+$/.test(grantsRaw)) return undefined;
|
|
162
|
+
const g = Number(grantsRaw);
|
|
163
|
+
if (!Number.isSafeInteger(g) || g < 0) return undefined;
|
|
164
|
+
grantsRemainingCents = g;
|
|
165
|
+
}
|
|
166
|
+
const out: CursorLimitStatus = {
|
|
167
|
+
isInSlowPool: slowRaw === '1',
|
|
168
|
+
grantsRemainingCents,
|
|
169
|
+
};
|
|
170
|
+
const stage = fields.get('stage');
|
|
171
|
+
if (stage !== undefined && stage.length > 0) out.stage = stage;
|
|
172
|
+
const slowMs = fields.get('slownessMs');
|
|
173
|
+
if (slowMs !== undefined && /^-?\d+(\.\d+)?$/.test(slowMs)) {
|
|
174
|
+
const v = Number(slowMs);
|
|
175
|
+
if (Number.isFinite(v)) out.slownessMs = v;
|
|
176
|
+
}
|
|
177
|
+
const exp = fields.get('grantsExpiryMs');
|
|
178
|
+
if (exp !== undefined && /^\d+$/.test(exp)) {
|
|
179
|
+
const v = Number(exp);
|
|
180
|
+
if (Number.isSafeInteger(v)) out.grantsExpiresAtMs = v;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return a new report whose notes carry the encoded status. Replaces any
|
|
189
|
+
* existing `cursorLimitStatus=` note; preserves all other notes. Idempotent.
|
|
190
|
+
*/
|
|
191
|
+
export function enhanceCursorReport(report: UsageReport, status: CursorLimitStatus): UsageReport {
|
|
192
|
+
const note = encodeCursorLimitStatusNote(status);
|
|
193
|
+
const prev = report.notes ?? [];
|
|
194
|
+
const kept = prev.filter((n) => !n.startsWith(CURSOR_LIMIT_STATUS_NOTE_PREFIX));
|
|
195
|
+
return { ...report, notes: [...kept, note] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch + parse Cursor limit status. Fail-soft: network / non-OK / no token /
|
|
200
|
+
* parse failure → null (or cached value within TTL). Success overwrites cache.
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchCursorLimitStatus(
|
|
203
|
+
opts?: FetchCursorLimitStatusOpts,
|
|
204
|
+
): Promise<CursorLimitStatus | null> {
|
|
205
|
+
const now = (opts?.nowMs ?? seamNowMs ?? Date.now)();
|
|
206
|
+
const fetchFn = opts?.fetchImpl ?? seamFetchImpl ?? fetch;
|
|
207
|
+
const resolveToken =
|
|
208
|
+
opts?.resolveCursorToken ??
|
|
209
|
+
seamResolveCursorToken ??
|
|
210
|
+
(async () => {
|
|
211
|
+
const resolved = await resolveCursorAccessToken();
|
|
212
|
+
return resolved?.accessToken ? { accessToken: resolved.accessToken } : null;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const auth = await resolveToken();
|
|
217
|
+
if (!auth?.accessToken) {
|
|
218
|
+
return softFail(now);
|
|
219
|
+
}
|
|
220
|
+
const res = await fetchFn(CURSOR_LIMIT_STATUS_URL, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: {
|
|
223
|
+
'content-type': 'application/json',
|
|
224
|
+
authorization: `Bearer ${auth.accessToken}`,
|
|
225
|
+
'Connect-Protocol-Version': '1',
|
|
226
|
+
'x-cursor-client-version': '0.1.0',
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({}),
|
|
229
|
+
signal: AbortSignal.timeout(opts?.timeoutMs ?? LIMIT_STATUS_TIMEOUT_MS),
|
|
230
|
+
});
|
|
231
|
+
if (!res.ok) return softFail(now);
|
|
232
|
+
let data: unknown;
|
|
233
|
+
try {
|
|
234
|
+
data = await res.json();
|
|
235
|
+
} catch {
|
|
236
|
+
return softFail(now);
|
|
237
|
+
}
|
|
238
|
+
const parsed = parseCursorLimitStatusResponse(data);
|
|
239
|
+
if (!parsed) return softFail(now);
|
|
240
|
+
cache = { status: parsed, atMs: now };
|
|
241
|
+
return parsed;
|
|
242
|
+
} catch {
|
|
243
|
+
return softFail(now);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function softFail(now: number): CursorLimitStatus | null {
|
|
248
|
+
if (cache && now - cache.atMs < LIMIT_STATUS_TTL_MS) return cache.status;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
package/lib/edit-variant-pin.ts
CHANGED
|
@@ -2,9 +2,11 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
|
|
5
|
+
import { overrideEditModelVariants } from './host-settings-adapter.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* Runtime edit-variant pin fallback for
|
|
8
|
+
* Runtime edit-variant pin, with a persisted fallback for hosts that no longer
|
|
9
|
+
* expose `edit.modelVariants` to the runtime settings API.
|
|
8
10
|
*
|
|
9
11
|
* The host hard-maps deepseek-v4-flash to the sloppy §/» marker grammar (it
|
|
10
12
|
* misreads hashline ranges), but the model repeatedly breaks sloppy control
|
|
@@ -12,11 +14,14 @@ import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
|
|
|
12
14
|
* plain old_string/new_string "replace" variant instead.
|
|
13
15
|
*
|
|
14
16
|
* Up to 17.x this extension did that via
|
|
15
|
-
* `settings.override("edit.modelVariants", …)
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* `settings.override("edit.modelVariants", …)`; omp 18.3 replaced that
|
|
18
|
+
* path-based API with registry handles (`edit.modelVariants` handle →
|
|
19
|
+
* `handle.override(settings, …)`, see `lib/host-settings-adapter.ts`), so the
|
|
20
|
+
* runtime path lives on. On 18.0.11–18.x hosts whose path-based schema
|
|
21
|
+
* dropped `edit.modelVariants`, `override()` throws inside `get()`: the
|
|
22
|
+
* path-segment table has no entry for it and `getByPath` iterates
|
|
23
|
+
* `undefined`. Those hosts still honor the key when it arrives through the
|
|
24
|
+
* global config file — `#getEditVariantEntries` reads the merged view
|
|
20
25
|
* directly and `resolveEditMode` prefers the per-model variant over
|
|
21
26
|
* `edit.mode` — so persisting once is functionally equivalent to the runtime
|
|
22
27
|
* pin.
|
|
@@ -102,38 +107,41 @@ export function persistEditVariantPin(
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/**
|
|
105
|
-
* Pin the edit variant for the target models.
|
|
110
|
+
* Pin the edit variant for the target models. Never rejects: every failure is
|
|
111
|
+
* warned through the host logger (the caller fires this and forgets it).
|
|
106
112
|
*
|
|
107
|
-
* Preferred path: in-memory
|
|
108
|
-
*
|
|
109
|
-
*
|
|
113
|
+
* Preferred path: the in-memory runtime override — the `edit.modelVariants`
|
|
114
|
+
* registry handle on omp >= 18.3, the path-based `settings.override` before
|
|
115
|
+
* that (both resolved inside `lib/host-settings-adapter.ts`). When this host
|
|
116
|
+
* offers no runtime path that reaches the setting (18.0.11+ dropped it from
|
|
117
|
+
* the path-based schema), persist into the global config.yml once —
|
|
118
|
+
* idempotent, effective from the next session.
|
|
110
119
|
*/
|
|
111
|
-
export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): void {
|
|
120
|
+
export async function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): Promise<void> {
|
|
112
121
|
// `pi.pi` is the whole SDK module namespace; its `settings` field has no
|
|
113
|
-
// declared runtime instance type on the extension-facing surface, so
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
| { override(path: string, value: unknown): void; getAgentDir?: () => string }
|
|
118
|
-
| undefined;
|
|
119
|
-
if (!settings) {
|
|
122
|
+
// declared runtime instance type on the extension-facing surface, so the
|
|
123
|
+
// `in`/`typeof` checks below are the whole contract.
|
|
124
|
+
const settings: unknown = pi.pi?.settings;
|
|
125
|
+
if (!settings || typeof settings !== 'object') {
|
|
120
126
|
pi.logger?.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
|
|
121
127
|
return;
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
try {
|
|
125
|
-
settings
|
|
126
|
-
return;
|
|
131
|
+
if (await overrideEditModelVariants(settings, pin)) return;
|
|
127
132
|
} catch {
|
|
128
|
-
/*
|
|
133
|
+
/* host schema no longer knows the path — persist instead */
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
try {
|
|
132
|
-
let agentDir
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
let agentDir = '';
|
|
138
|
+
if ('getAgentDir' in settings && typeof settings.getAgentDir === 'function') {
|
|
139
|
+
try {
|
|
140
|
+
const dir = settings.getAgentDir();
|
|
141
|
+
if (typeof dir === 'string') agentDir = dir;
|
|
142
|
+
} catch {
|
|
143
|
+
agentDir = '';
|
|
144
|
+
}
|
|
137
145
|
}
|
|
138
146
|
if (!agentDir) agentDir = path.join(os.homedir(), '.omp', 'agent');
|
|
139
147
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host Settings API compatibility adapter (change: omp-18.3-settings-api-compat).
|
|
3
|
+
*
|
|
4
|
+
* omp 18.3.2 removed the path-based Settings API (`settings.override(path, value)`
|
|
5
|
+
* / `settings.clearOverride(path)`) in favour of registry handles: a handle
|
|
6
|
+
* carries the setting's identity — `handle.override(settings, value)` writes the
|
|
7
|
+
* runtime overlay and clearing goes through
|
|
8
|
+
* `settings.clearOverrideValue(handle)` (equivalent to
|
|
9
|
+
* `handle.clearOverride(settings)`). Every path-based call this plugin made
|
|
10
|
+
* (`clearOverride('modelRoles')`, `override('task.agentModelOverrides', …)`,
|
|
11
|
+
* `override('providers.maxInFlightRequests', …)`,
|
|
12
|
+
* `override('edit.modelVariants', …)`) now throws
|
|
13
|
+
* (`settings.clearOverride is not a function`), which took down the whole
|
|
14
|
+
* `/pick-model` apply path and silently disabled the concurrency hot-apply.
|
|
15
|
+
*
|
|
16
|
+
* Handles live on deep host subpaths and are loaded LAZILY via dynamic
|
|
17
|
+
* `import()` — never a static import:
|
|
18
|
+
* - this package's own dev dependency is older (18.1.2) and ships no such
|
|
19
|
+
* subpaths, so a static import would break typecheck/build;
|
|
20
|
+
* - the host's legacy-pi-compat redirect rewrites these specifiers to the
|
|
21
|
+
* host's own module instances, which is the singleton the session reads
|
|
22
|
+
* (a separately `require()`d copy would be a no-op target for writes).
|
|
23
|
+
*
|
|
24
|
+
* The specifiers MUST BE STRING LITERALS — `import('@oh-my-pi/pi-coding-agent/
|
|
25
|
+
* config/model-settings')` — never a computed/concatenated string. The host
|
|
26
|
+
* rewrites extension sources textually (it collects string-literal import
|
|
27
|
+
* references and swaps them for absolute host paths), so a runtime-built
|
|
28
|
+
* specifier is not seen by the rewriter and resolves natively against this
|
|
29
|
+
* package's own 18.1.2 copy, i.e. the handle silently stays `undefined` on
|
|
30
|
+
* exactly the hosts this adapter exists for. `types/host-settings-modules.d.ts`
|
|
31
|
+
* supplies the build-time declarations those literal specifiers need.
|
|
32
|
+
* Each specifier is attempted independently of the others and the result (hit
|
|
33
|
+
* or miss) is cached for the process lifetime, so a host without these
|
|
34
|
+
* subpaths pays each failed import exactly once.
|
|
35
|
+
*
|
|
36
|
+
* Every helper falls back to the path-based API when the handle is missing, so
|
|
37
|
+
* hosts <= 18.1 keep their previous behaviour byte-for-byte.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Minimal host registry-handle surface (omp >= 18.3 settings API). */
|
|
41
|
+
export interface HostSettingHandle {
|
|
42
|
+
/** Write this setting's runtime override (never persisted). */
|
|
43
|
+
override(settings: unknown, value: unknown): void;
|
|
44
|
+
/**
|
|
45
|
+
* Clear this setting's runtime override. Present on handles, but the
|
|
46
|
+
* documented clear path is `settings.clearOverrideValue(handle)`, so the
|
|
47
|
+
* adapter tries that first (see {@link clearModelRolesRuntimeOverride}).
|
|
48
|
+
*/
|
|
49
|
+
clearOverride?(settings: unknown): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolved handle per setting this plugin touches; a miss stays `undefined`. */
|
|
53
|
+
export interface HostSettingsHandles {
|
|
54
|
+
/** `cfgModelRoles` — `config/model-settings`. */
|
|
55
|
+
modelRoles?: HostSettingHandle;
|
|
56
|
+
/** `cfgTaskAgentModelOverrides` — `task/settings`. */
|
|
57
|
+
taskAgentModelOverrides?: HostSettingHandle;
|
|
58
|
+
/** `cfgProvidersMaxInFlightRequests` — `session/settings`. */
|
|
59
|
+
providersMaxInFlight?: HostSettingHandle;
|
|
60
|
+
/** `cfgEditModelVariants` — `edit/settings`. */
|
|
61
|
+
editModelVariants?: HostSettingHandle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface HandleSpec {
|
|
65
|
+
key: keyof HostSettingsHandles;
|
|
66
|
+
exportName: string;
|
|
67
|
+
/**
|
|
68
|
+
* Literal-specifier loader (see the header: a computed specifier would not
|
|
69
|
+
* be rewritten by the host loader). Dynamic, so an older host's missing
|
|
70
|
+
* subpath rejects instead of breaking module evaluation.
|
|
71
|
+
*/
|
|
72
|
+
load: () => Promise<unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HANDLE_SPECS: readonly HandleSpec[] = [
|
|
76
|
+
{
|
|
77
|
+
key: 'modelRoles',
|
|
78
|
+
exportName: 'cfgModelRoles',
|
|
79
|
+
load: () => import('@oh-my-pi/pi-coding-agent/config/model-settings'),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: 'taskAgentModelOverrides',
|
|
83
|
+
exportName: 'cfgTaskAgentModelOverrides',
|
|
84
|
+
load: () => import('@oh-my-pi/pi-coding-agent/task/settings'),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'providersMaxInFlight',
|
|
88
|
+
exportName: 'cfgProvidersMaxInFlightRequests',
|
|
89
|
+
load: () => import('@oh-my-pi/pi-coding-agent/session/settings'),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: 'editModelVariants',
|
|
93
|
+
exportName: 'cfgEditModelVariants',
|
|
94
|
+
load: () => import('@oh-my-pi/pi-coding-agent/edit/settings'),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
async function loadHandle(spec: HandleSpec): Promise<HostSettingHandle | undefined> {
|
|
99
|
+
try {
|
|
100
|
+
const mod = (await spec.load()) as Record<string, unknown> | null | undefined;
|
|
101
|
+
const candidate = mod?.[spec.exportName] as Partial<HostSettingHandle> | undefined;
|
|
102
|
+
if (candidate && typeof candidate.override === 'function') return candidate as HostSettingHandle;
|
|
103
|
+
} catch {
|
|
104
|
+
/* subpath absent on this host (<= 18.1) — the path-based fallback applies */
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let handlesPromise: Promise<HostSettingsHandles> | null = null;
|
|
110
|
+
let handlesForTest: HostSettingsHandles | null | undefined;
|
|
111
|
+
|
|
112
|
+
/** Test-only seam: inject fake handles, bypassing the dynamic imports. */
|
|
113
|
+
export function _setHandlesForTest(handles: HostSettingsHandles | null | undefined): void {
|
|
114
|
+
handlesForTest = handles;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Test-only seam: drop injected handles and any cached load. */
|
|
118
|
+
export function _resetForTest(): void {
|
|
119
|
+
handlesForTest = undefined;
|
|
120
|
+
handlesPromise = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Handles of the running host, loaded once per process. A host that lacks the
|
|
125
|
+
* subpaths yields `{}` — cached, so the failed imports run exactly once.
|
|
126
|
+
*/
|
|
127
|
+
export function hostSettingsHandles(): Promise<HostSettingsHandles> {
|
|
128
|
+
if (handlesForTest !== undefined) return Promise.resolve(handlesForTest ?? {});
|
|
129
|
+
handlesPromise ??= (async () => {
|
|
130
|
+
const out: HostSettingsHandles = {};
|
|
131
|
+
await Promise.all(
|
|
132
|
+
HANDLE_SPECS.map(async (spec) => {
|
|
133
|
+
const handle = await loadHandle(spec);
|
|
134
|
+
if (handle) out[spec.key] = handle;
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
return out;
|
|
138
|
+
})();
|
|
139
|
+
return handlesPromise;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Path-based Settings API on hosts <= 18.1. */
|
|
143
|
+
interface PathSettings {
|
|
144
|
+
override?(path: string, value: unknown): void;
|
|
145
|
+
clearOverride?(path: string): void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Handle-clearing Settings API on hosts >= 18.3. */
|
|
149
|
+
interface HandleClearingSettings {
|
|
150
|
+
clearOverrideValue?(handle: unknown): void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Shared write path: registry handle first, path-based `override` second. */
|
|
154
|
+
async function writeOverride(
|
|
155
|
+
handleKey: keyof HostSettingsHandles,
|
|
156
|
+
legacyPath: string,
|
|
157
|
+
settings: unknown,
|
|
158
|
+
value: unknown,
|
|
159
|
+
): Promise<boolean> {
|
|
160
|
+
const handle = (await hostSettingsHandles())[handleKey];
|
|
161
|
+
if (handle) {
|
|
162
|
+
handle.override(settings, value);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
const legacy = typeof settings === 'object' && settings !== null ? (settings as PathSettings) : null;
|
|
166
|
+
if (legacy && typeof legacy.override === 'function') {
|
|
167
|
+
legacy.override(legacyPath, value);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Clear the session-scoped `modelRoles` overlay.
|
|
175
|
+
*
|
|
176
|
+
* omp >= 18.3: `settings.clearOverrideValue(cfgModelRoles)` (falling back to
|
|
177
|
+
* `handle.clearOverride(settings)`). <= 18.1: `settings.clearOverride('modelRoles')`.
|
|
178
|
+
*
|
|
179
|
+
* Returns `false` when neither API is present — callers warn and keep writing
|
|
180
|
+
* (an absent *clear* API does not mean settings are unusable).
|
|
181
|
+
*/
|
|
182
|
+
export async function clearModelRolesRuntimeOverride(settings: unknown): Promise<boolean> {
|
|
183
|
+
const handle = (await hostSettingsHandles()).modelRoles;
|
|
184
|
+
if (handle) {
|
|
185
|
+
const clearing = typeof settings === 'object' && settings !== null
|
|
186
|
+
? (settings as HandleClearingSettings)
|
|
187
|
+
: null;
|
|
188
|
+
if (clearing && typeof clearing.clearOverrideValue === 'function') {
|
|
189
|
+
clearing.clearOverrideValue(handle);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (typeof handle.clearOverride === 'function') {
|
|
193
|
+
handle.clearOverride(settings);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const legacy = typeof settings === 'object' && settings !== null ? (settings as PathSettings) : null;
|
|
198
|
+
if (legacy && typeof legacy.clearOverride === 'function') {
|
|
199
|
+
legacy.clearOverride('modelRoles');
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Write the `task.agentModelOverrides` neutralization (handle API, else path API). */
|
|
206
|
+
export function applyAgentModelOverrides(
|
|
207
|
+
settings: unknown,
|
|
208
|
+
value: Record<string, string>,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
return writeOverride('taskAgentModelOverrides', 'task.agentModelOverrides', settings, value);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Hot-apply the complete `providers.maxInFlightRequests` map (handle API, else path API). */
|
|
214
|
+
export function overrideProviderMaxInFlight(
|
|
215
|
+
settings: unknown,
|
|
216
|
+
value: Record<string, number>,
|
|
217
|
+
): Promise<boolean> {
|
|
218
|
+
return writeOverride('providersMaxInFlight', 'providers.maxInFlightRequests', settings, value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Pin `edit.modelVariants` at runtime (handle API, else path API). */
|
|
222
|
+
export function overrideEditModelVariants(
|
|
223
|
+
settings: unknown,
|
|
224
|
+
value: Record<string, string>,
|
|
225
|
+
): Promise<boolean> {
|
|
226
|
+
return writeOverride('editModelVariants', 'edit.modelVariants', settings, value);
|
|
227
|
+
}
|
package/lib/model-roles.ts
CHANGED
|
@@ -17,13 +17,16 @@
|
|
|
17
17
|
* commit/tiny via enumeration fallbacks terminating at smol, task via
|
|
18
18
|
* primary-session inheritance, plan as a graceful no-op, advisor via the
|
|
19
19
|
* static slow chain, custom roles via the config layer. The landing sequence
|
|
20
|
-
* is unchanged:
|
|
21
|
-
* empty-string neutralization of persisted per-agent pins
|
|
20
|
+
* is unchanged: clear the modelRoles session overlay → overrideModelRoles(payload)
|
|
21
|
+
* → empty-string neutralization of persisted per-agent pins (the clear and the
|
|
22
|
+
* neutralization go through `lib/host-settings-adapter.ts`: registry handles on
|
|
23
|
+
* omp >= 18.3, path-based settings calls before that).
|
|
22
24
|
*/
|
|
23
25
|
import { type ModelRole } from '@oh-my-pi/pi-coding-agent/config/model-roles';
|
|
24
26
|
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
25
27
|
import type { SelectionContext } from '../index.js';
|
|
26
28
|
import type { TierName } from './model-tiers.js';
|
|
29
|
+
import { applyAgentModelOverrides, clearModelRolesRuntimeOverride } from './host-settings-adapter.js';
|
|
27
30
|
import { OPSX_AGENTS } from './unified-config.js';
|
|
28
31
|
|
|
29
32
|
/** Per-agent names written into `task.agentModelOverrides` neutralization. */
|
|
@@ -138,8 +141,8 @@ export function resolveOmpRoleTier(
|
|
|
138
141
|
* Build the minimal write-set overlay plan (design D5): for each role in
|
|
139
142
|
* `OMP_WRITE_SET_ROLES`, resolve its tier (or `skip`), apply capability
|
|
140
143
|
* gating, then assign the single picked model for the tier. Roles without a
|
|
141
|
-
* pick are recorded with a reason and omitted from the payload — after
|
|
142
|
-
*
|
|
144
|
+
* pick are recorded with a reason and omitted from the payload — after the
|
|
145
|
+
* pre-clear they fall back to the user's config layer (or stay
|
|
143
146
|
* unconfigured). Roles outside the write set are never iterated.
|
|
144
147
|
*/
|
|
145
148
|
export function buildRoleOverridePlan(input: BuildRoleOverridePlanInput): RoleOverridePlan {
|
|
@@ -226,11 +229,26 @@ export function agentOverrideNeutralization(): Record<string, string> {
|
|
|
226
229
|
return neutral;
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* Minimal Settings surface this module needs (host-process `pi.pi.settings`).
|
|
234
|
+
*
|
|
235
|
+
* Deliberately a union of both host generations: `overrideModelRoles` (the
|
|
236
|
+
* session overlay write) exists in every version, while clearing and the
|
|
237
|
+
* path-based writes moved from `clearOverride(path)` / `override(path, value)`
|
|
238
|
+
* (<= 18.1) to registry handles (`clearOverrideValue(handle)`, see
|
|
239
|
+
* `lib/host-settings-adapter.ts`) in 18.3. Optional members keep every
|
|
240
|
+
* generation assignable; `applyRoleModelOverrides` never assumes a method
|
|
241
|
+
* exists.
|
|
242
|
+
*/
|
|
230
243
|
export interface RoleSettings {
|
|
231
|
-
|
|
244
|
+
/** Session overlay write (limited to `{smol, default, slow, vision}` by the plan). */
|
|
232
245
|
overrideModelRoles(roles: Record<string, string>): void;
|
|
233
|
-
|
|
246
|
+
/** omp <= 18.1: clear the `modelRoles` runtime overlay by path. */
|
|
247
|
+
clearOverride?(key: string): void;
|
|
248
|
+
/** omp >= 18.3: clear a runtime overlay through its registry handle. */
|
|
249
|
+
clearOverrideValue?(handle: unknown): void;
|
|
250
|
+
/** omp <= 18.1: path-based runtime override. */
|
|
251
|
+
override?(key: string, value: unknown): void;
|
|
234
252
|
}
|
|
235
253
|
|
|
236
254
|
/**
|
|
@@ -243,25 +261,36 @@ export interface RoleSettings {
|
|
|
243
261
|
* picked model are omitted and fall back to the config layer after the
|
|
244
262
|
* clear) → neutralize persisted per-agent pins with empty strings. Never writes
|
|
245
263
|
* disk; the runtime overlay is released with the session.
|
|
264
|
+
*
|
|
265
|
+
* Async because clearing/writing goes through `lib/host-settings-adapter.ts`,
|
|
266
|
+
* whose host handles are loaded by dynamic import; every call site awaits.
|
|
267
|
+
* Clearing and the neutralization write degrade to a warn when this host
|
|
268
|
+
* offers neither API generation — the missing *clear* is not a reason to skip
|
|
269
|
+
* the overlay write itself (the plan payload still applies).
|
|
246
270
|
*/
|
|
247
|
-
export function applyRoleModelOverrides(
|
|
271
|
+
export async function applyRoleModelOverrides(
|
|
248
272
|
settings: RoleSettings | null | undefined,
|
|
249
273
|
sel: SelectionContext,
|
|
250
274
|
warn: (msg: string) => void = () => {},
|
|
251
|
-
): void {
|
|
275
|
+
): Promise<void> {
|
|
252
276
|
if (!settings) {
|
|
253
277
|
warn('[omp-opsx-addon] pi.pi.settings unavailable; model role overrides skipped');
|
|
254
278
|
return;
|
|
255
279
|
}
|
|
256
|
-
settings
|
|
280
|
+
if (!(await clearModelRolesRuntimeOverride(settings))) {
|
|
281
|
+
warn('[omp-opsx-addon] no settings API to clear the modelRoles session overlay (host handles and path-based clearOverride both unavailable); writing the overlay without a pre-clear — stale roles from a previous selection may linger');
|
|
282
|
+
}
|
|
257
283
|
settings.overrideModelRoles(sel.roleOverrides);
|
|
258
|
-
settings
|
|
284
|
+
const neutralized = await applyAgentModelOverrides(settings, agentOverrideNeutralization());
|
|
285
|
+
if (!neutralized) {
|
|
286
|
+
warn('[omp-opsx-addon] no settings API for task.agentModelOverrides (host handles and path-based override both unavailable)');
|
|
287
|
+
}
|
|
259
288
|
const applied = Object.keys(sel.roleOverrides);
|
|
260
289
|
const skippedText = sel.skippedRoles.length > 0
|
|
261
290
|
? `; skipped: ${sel.skippedRoles.map((s) => `${s.role}(${s.reason})`).join(', ')}`
|
|
262
291
|
: '';
|
|
263
292
|
const skipWarns = sel.skippedRoles.filter((s) => s.reason !== 'skip');
|
|
264
|
-
warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins neutralized`);
|
|
293
|
+
warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins ${neutralized ? 'neutralized' : 'NOT neutralized'}`);
|
|
265
294
|
for (const s of skipWarns) {
|
|
266
295
|
if (s.reason === 'capability') {
|
|
267
296
|
warn(`[omp-opsx-addon] model role "${s.role}": no capable model in selection; skipped (role keeps configured/default value; change selector/allowlist or set model_role_tiers.${s.role}: skip)`);
|
package/lib/model-selector.ts
CHANGED
|
@@ -130,7 +130,11 @@ export function candidateQuota(
|
|
|
130
130
|
const win = health.windows.find((w) => w.id === bucket) ?? health.windows.find((w) => w.id === 'other');
|
|
131
131
|
const ondemand = health.windows.find((w) => w.id === 'ondemand');
|
|
132
132
|
if (!win) {
|
|
133
|
-
|
|
133
|
+
// D6: provider-level exhaustion was relaxed for slow-pool cursor; the
|
|
134
|
+
// !win fallback must NOT get more permissive than pre-change behavior.
|
|
135
|
+
const cls = health.cursorLimitStatus;
|
|
136
|
+
const slowPoolAlive = !!cls && (cls.isInSlowPool || cls.grantsRemainingCents > 0);
|
|
137
|
+
return { exhausted: health.exhausted || slowPoolAlive, remainingFraction: health.worstRemainingFraction };
|
|
134
138
|
}
|
|
135
139
|
if (win.exhausted) {
|
|
136
140
|
if (ondemand && !ondemand.exhausted) {
|
package/lib/usage-poller.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { homedir } from 'os';
|
|
|
56
56
|
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
57
57
|
import type { AuthStorageLike, ProviderHealth, DirectFetcher, ApiKeyResolver } from './usage-resolver.js';
|
|
58
58
|
import { resolveProviderReports, buildHealthMap, canonicalizeProvider, listDetectableProviders } from './usage-resolver.js';
|
|
59
|
+
import { fetchCursorLimitStatus, enhanceCursorReport } from './cursor-limit-status.js';
|
|
59
60
|
import { RedisRelay } from './usage-redis-probe.js';
|
|
60
61
|
import type { RedisTransport } from './usage-redis-client.js';
|
|
61
62
|
import type { UsageBucket } from './usage-estimator.js';
|
|
@@ -979,8 +980,16 @@ async function fetchProvider(
|
|
|
979
980
|
lastFetchedByProvider.set(canonical, fetchedAt);
|
|
980
981
|
// Replace (or insert) just this provider's report; keep all others.
|
|
981
982
|
const byId = new Map(cachedReports.map((r) => [r.provider, r]));
|
|
982
|
-
|
|
983
|
+
let newReport = reports.find((r) => r.provider === canonical);
|
|
983
984
|
if (newReport) {
|
|
985
|
+
// Cursor slow-pool/grants enhancement (D1): rides the existing fetch
|
|
986
|
+
// cadence; failure is fail-soft inside fetchCursorLimitStatus and must
|
|
987
|
+
// NOT re-mark dirty (the outer catch only guards the report fetch).
|
|
988
|
+
// On null the report lands as-is — current behavior (exhausted).
|
|
989
|
+
if (canonical === 'cursor') {
|
|
990
|
+
const limitStatus = await fetchCursorLimitStatus();
|
|
991
|
+
if (limitStatus) newReport = enhanceCursorReport(newReport, limitStatus);
|
|
992
|
+
}
|
|
984
993
|
byId.set(canonical, newReport);
|
|
985
994
|
// Fresh report landed → the provider's health is CONFIRMED.
|
|
986
995
|
unconfirmedProviders.delete(canonical);
|
package/lib/usage-render.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface Painter {
|
|
|
42
42
|
pct(p: number): string;
|
|
43
43
|
/** Exhausted-window marker text, error-colored. */
|
|
44
44
|
exhausted(text: string): string;
|
|
45
|
+
/** Warning / soft-degraded marker (e.g. Cursor ⚠降速), warning-colored. */
|
|
46
|
+
warning(text: string): string;
|
|
45
47
|
/** Inter-column separator, already dyed incl. flanking spaces (3 cells). */
|
|
46
48
|
sep: string;
|
|
47
49
|
/** Provider header. `brandAnsi` is a raw brand-color SGR (or "" when none). */
|
|
@@ -101,6 +103,7 @@ export const ansiPainter: Painter = {
|
|
|
101
103
|
return c(col === 'error' ? X : col === 'warning' ? Y : G, pctValue(p));
|
|
102
104
|
},
|
|
103
105
|
exhausted: (text) => c(X, text),
|
|
106
|
+
warning: (text) => c(Y, text),
|
|
104
107
|
sep: ` ${D}│${R} `,
|
|
105
108
|
header: (label, brandAnsi) => c(brandAnsi ? BOLD + brandAnsi : BOLD, label),
|
|
106
109
|
balance: (text) => c(WHITE, text),
|
|
@@ -113,6 +116,7 @@ export function themePainter(theme: PainterTheme): Painter {
|
|
|
113
116
|
return {
|
|
114
117
|
pct: (p) => theme.fg(pctColor(p), pctValue(p)),
|
|
115
118
|
exhausted: (text) => theme.fg('error', text),
|
|
119
|
+
warning: (text) => theme.fg('warning', text),
|
|
116
120
|
sep: ` ${theme.fg('dim', '│')} `,
|
|
117
121
|
header: (label, brandAnsi) => (brandAnsi ? c(brandAnsi, label) : theme.fg('accent', label)),
|
|
118
122
|
balance: (text) => theme.fg('text', text),
|
|
@@ -135,6 +139,7 @@ const PROVIDER: Record<string, { label: string; color: string }> = {
|
|
|
135
139
|
'kimi-code': { label: 'Kimi', color: CYAN },
|
|
136
140
|
'minimax-code-cn': { label: 'MiniMax', color: X },
|
|
137
141
|
'ark-coding-plan': { label: '火山方舟', color: CYAN },
|
|
142
|
+
'alibaba-token-plan': { label: '千问云', color: PURPLE },
|
|
138
143
|
deepseek: { label: 'DeepSeek', color: BLUE },
|
|
139
144
|
cursor: { label: 'Cursor', color: CYAN },
|
|
140
145
|
'opencode-go': { label: 'OpenCode', color: G },
|
|
@@ -349,6 +354,30 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
|
|
|
349
354
|
// column visible rather than dropping a logged-in provider.
|
|
350
355
|
return isCursor ? [header, painter.balance('—')] : undefined;
|
|
351
356
|
}
|
|
357
|
+
// Cursor slow-pool / grants markers (provider not exhausted — chips path).
|
|
358
|
+
// Status lives on its own line: bucket chips first, then ⚠降速 / Grants /
|
|
359
|
+
// reset countdown. No cursorLimitStatus note → single chips line, exactly
|
|
360
|
+
// as before the change.
|
|
361
|
+
if (isCursor && health.cursorLimitStatus) {
|
|
362
|
+
const cls = health.cursorLimitStatus;
|
|
363
|
+
const status: string[] = [];
|
|
364
|
+
if (cls.isInSlowPool) status.push(painter.warning('⚠降速'));
|
|
365
|
+
if (cls.grantsRemainingCents > 0) {
|
|
366
|
+
status.push(painter.balance(`Grants $${(cls.grantsRemainingCents / 100).toFixed(2)}`));
|
|
367
|
+
}
|
|
368
|
+
// Included buckets reset with the billing period — surface the soonest
|
|
369
|
+
// reset so a slow-pooled user can see when quota revives. Reuses the
|
|
370
|
+
// existing exhausted-path formatter; hidden when no resetsAt.
|
|
371
|
+
let soonest: number | undefined;
|
|
372
|
+
for (const l of r.limits) {
|
|
373
|
+
const at = l.window?.resetsAt;
|
|
374
|
+
if (typeof at !== 'number') continue;
|
|
375
|
+
soonest = soonest === undefined ? at : Math.min(soonest, at);
|
|
376
|
+
}
|
|
377
|
+
if (soonest !== undefined) status.push(`${resetIn(soonest)}重置`);
|
|
378
|
+
if (status.length === 0) return [header, parts.join(' · ')];
|
|
379
|
+
return [header, parts.join(' · '), status.join(' · ')];
|
|
380
|
+
}
|
|
352
381
|
return [header, parts.join(' · ')];
|
|
353
382
|
};
|
|
354
383
|
|
package/lib/usage-resolver.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { UsageReport, UsageStatus } from '@oh-my-pi/pi-ai';
|
|
16
|
+
import { parseCursorLimitStatusNote, type CursorLimitStatus } from './cursor-limit-status.js';
|
|
16
17
|
|
|
17
18
|
export type ApiKeyResolver = (provider: string) => Promise<string | undefined>;
|
|
18
19
|
|
|
@@ -100,6 +101,8 @@ export interface ProviderHealth {
|
|
|
100
101
|
exhaustedWindows: string[];
|
|
101
102
|
fetchedAt: number;
|
|
102
103
|
notes?: string[];
|
|
104
|
+
/** Cursor slow-pool / grants echo (from `cursorLimitStatus=` note); render + selector consume this. */
|
|
105
|
+
cursorLimitStatus?: CursorLimitStatus;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
const HEALTHY_REMAINING_THRESHOLD = 0.1;
|
|
@@ -262,6 +265,8 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
|
262
265
|
|
|
263
266
|
let exhausted = false;
|
|
264
267
|
let worstRemaining: number | undefined;
|
|
268
|
+
const limitStatus =
|
|
269
|
+
report.provider === 'cursor' ? parseCursorLimitStatusNote(report.notes) : undefined;
|
|
265
270
|
|
|
266
271
|
if (policy === 'bucket') {
|
|
267
272
|
const byId = new Map(windows.map((w) => [w.id, w]));
|
|
@@ -277,6 +282,17 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
|
277
282
|
if (w.remaining === undefined) continue;
|
|
278
283
|
if (worstRemaining === undefined || w.remaining > worstRemaining) worstRemaining = w.remaining;
|
|
279
284
|
}
|
|
285
|
+
// D3: slow-pool / grants keep provider usable (warning) instead of exhausted.
|
|
286
|
+
if (
|
|
287
|
+
exhausted &&
|
|
288
|
+
limitStatus &&
|
|
289
|
+
(limitStatus.isInSlowPool || limitStatus.grantsRemainingCents > 0)
|
|
290
|
+
) {
|
|
291
|
+
exhausted = false;
|
|
292
|
+
status = 'warning';
|
|
293
|
+
} else if (!exhausted && limitStatus?.isInSlowPool && status === 'ok') {
|
|
294
|
+
status = 'warning';
|
|
295
|
+
}
|
|
280
296
|
} else if (policy === 'balance') {
|
|
281
297
|
const win = windows[0];
|
|
282
298
|
exhausted = windows.some((w) => w.exhausted) || (win?.remaining !== undefined && win.remaining <= 0);
|
|
@@ -315,6 +331,7 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
|
315
331
|
exhaustedWindows,
|
|
316
332
|
fetchedAt: report.fetchedAt,
|
|
317
333
|
notes: report.notes,
|
|
334
|
+
cursorLimitStatus: limitStatus,
|
|
318
335
|
};
|
|
319
336
|
}
|
|
320
337
|
|
|
@@ -327,7 +344,11 @@ export function healthCacheKey(health: Map<string, ProviderHealth> | null | unde
|
|
|
327
344
|
const h = health.get(p)!;
|
|
328
345
|
const wins = h.windows.map((w) => `${w.id}:${w.exhausted ? 1 : 0}:${w.remaining ?? ''}`).join(',');
|
|
329
346
|
const ab = (h.autoBucketModels ?? []).join('+');
|
|
330
|
-
|
|
347
|
+
// Cursor only: boolean bits (not raw cents) so grants churn does not invalidate selection cache.
|
|
348
|
+
const cls = h.cursorLimitStatus
|
|
349
|
+
? `;sp=${h.cursorLimitStatus.isInSlowPool ? 1 : 0};g=${h.cursorLimitStatus.grantsRemainingCents > 0 ? 1 : 0}`
|
|
350
|
+
: '';
|
|
351
|
+
return `${p}=${h.exhausted ? 1 : 0};${wins};${ab}${cls}`;
|
|
331
352
|
})
|
|
332
353
|
.join('|');
|
|
333
354
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declarations for the host Settings registry-handle subpaths
|
|
3
|
+
* (change: omp-18.3-settings-api-compat).
|
|
4
|
+
*
|
|
5
|
+
* `lib/host-settings-adapter.ts` must import these four modules with LITERAL
|
|
6
|
+
* specifiers: the host's legacy-pi-compat loader rewrites extension sources
|
|
7
|
+
* textually (it collects string-literal import/import() references and replaces
|
|
8
|
+
* them with absolute host paths), so a computed specifier would silently resolve
|
|
9
|
+
* against this package's own `@oh-my-pi/pi-coding-agent` dev dependency
|
|
10
|
+
* (18.1.2), which ships none of these subpaths.
|
|
11
|
+
*
|
|
12
|
+
* Literal specifiers, however, are resolved by `tsc` at build time — hence these
|
|
13
|
+
* declarations. Their shape mirrors the host's `Setting` handle: `override` is
|
|
14
|
+
* the write path, `clearOverride` is the (optional) clear path that
|
|
15
|
+
* `Settings.clearOverrideValue(handle)` performs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
declare module '@oh-my-pi/pi-coding-agent/config/model-settings' {
|
|
19
|
+
/** Registered setting handle `modelRoles`. */
|
|
20
|
+
export const cfgModelRoles: {
|
|
21
|
+
override(settings: unknown, value: unknown): void;
|
|
22
|
+
clearOverride?(settings: unknown): void;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module '@oh-my-pi/pi-coding-agent/task/settings' {
|
|
27
|
+
/** Registered setting handle `task.agentModelOverrides`. */
|
|
28
|
+
export const cfgTaskAgentModelOverrides: {
|
|
29
|
+
override(settings: unknown, value: unknown): void;
|
|
30
|
+
clearOverride?(settings: unknown): void;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare module '@oh-my-pi/pi-coding-agent/session/settings' {
|
|
35
|
+
/** Registered setting handle `providers.maxInFlightRequests`. */
|
|
36
|
+
export const cfgProvidersMaxInFlightRequests: {
|
|
37
|
+
override(settings: unknown, value: unknown): void;
|
|
38
|
+
clearOverride?(settings: unknown): void;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare module '@oh-my-pi/pi-coding-agent/edit/settings' {
|
|
43
|
+
/** Registered setting handle `edit.modelVariants`. */
|
|
44
|
+
export const cfgEditModelVariants: {
|
|
45
|
+
override(settings: unknown, value: unknown): void;
|
|
46
|
+
clearOverride?(settings: unknown): void;
|
|
47
|
+
};
|
|
48
|
+
}
|