@oh-my-pi/pi-ai 18.3.4 → 18.4.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/CHANGELOG.md +35 -48
- package/dist/types/auth/blocks.d.ts +10 -19
- package/dist/types/auth/cascade.d.ts +1 -1
- package/dist/types/auth/rotation.d.ts +6 -3
- package/dist/types/auth/types.d.ts +23 -3
- package/dist/types/auth/usage-cache.d.ts +14 -2
- package/dist/types/auth/usage.d.ts +2 -2
- package/dist/types/auth-broker/client.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +5 -6
- package/dist/types/auth-broker/types.d.ts +3 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +2 -1
- package/dist/types/auth-retry.d.ts +22 -13
- package/dist/types/images/types.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/types.d.ts +3 -2
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +10 -0
- package/dist/types/providers/cursor.d.ts +6 -2
- package/dist/types/providers/google-shared.d.ts +13 -2
- package/dist/types/types.d.ts +0 -10
- package/dist/types/usage/claude-reset.d.ts +6 -0
- package/dist/types/usage/claude.d.ts +6 -0
- package/dist/types/usage.d.ts +2 -0
- package/dist/types/utils/proxy.d.ts +2 -2
- package/package.json +6 -6
- package/src/auth/blocks.ts +79 -37
- package/src/auth/cascade.ts +5 -4
- package/src/auth/resets.ts +104 -40
- package/src/auth/rotation.ts +62 -21
- package/src/auth/select.ts +9 -2
- package/src/auth/types.ts +28 -3
- package/src/auth/usage-cache.ts +45 -2
- package/src/auth/usage.ts +52 -25
- package/src/auth-broker/client.ts +13 -0
- package/src/auth-broker/remote-store.ts +128 -27
- package/src/auth-broker/server.ts +20 -0
- package/src/auth-broker/types.ts +4 -1
- package/src/auth-broker/wire-schemas.ts +7 -0
- package/src/auth-retry.ts +50 -28
- package/src/images/openai-hosted.ts +18 -3
- package/src/images/types.ts +9 -0
- package/src/index.ts +1 -0
- package/src/judgment/types.ts +10 -6
- package/src/judgment/typesafe.ts +2 -2
- package/src/providers/anthropic.ts +54 -123
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor/proto/agent.proto +5 -0
- package/src/providers/cursor-pi-args.ts +14 -0
- package/src/providers/cursor.ts +66 -28
- package/src/providers/google-gemini-cli.ts +2 -19
- package/src/providers/google-shared.ts +30 -22
- package/src/providers/ollama.ts +2 -0
- package/src/registry/oauth/oauth.html +206 -185
- package/src/stream.ts +2 -284
- package/src/types.ts +0 -10
- package/src/usage/claude-reset.ts +45 -11
- package/src/usage/claude.ts +79 -49
- package/src/usage.ts +2 -0
- package/src/utils/proxy.ts +3 -3
- package/src/utils/schema/CONSTRAINTS.md +30 -28
- package/src/utils/schema/normalize.ts +1 -1
- package/src/utils/schema/wire.ts +3 -3
|
@@ -161,6 +161,11 @@ interface UsageCacheEntry {
|
|
|
161
161
|
fetchedAt: number;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Identity of one credential block row: credential, provider key, and scope. */
|
|
165
|
+
function blockKey(credentialId: number, block: { providerKey: string; blockScope: string }): string {
|
|
166
|
+
return `${credentialId}\0${block.providerKey}\0${block.blockScope}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
164
169
|
function usageOverlayKey(
|
|
165
170
|
provider: Provider,
|
|
166
171
|
ids: { accountId?: string; email?: string; projectId?: string; orgId?: string },
|
|
@@ -278,6 +283,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
278
283
|
#usageCache?: UsageCacheEntry;
|
|
279
284
|
#usageInflight?: Promise<UsageReport[] | null>;
|
|
280
285
|
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
286
|
+
/** Exact deleted rows suppressed until their old deadline, including snapshots racing the DELETE acknowledgement. */
|
|
287
|
+
#deletedCredentialBlocks: Map<string, CredentialBlockSnapshot> = new Map();
|
|
288
|
+
/** Local block writes not yet reflected by the broker; see {@link #pendingBlocksFor}. */
|
|
289
|
+
#pendingCredentialBlocks: Map<string, CredentialBlockSnapshot> = new Map();
|
|
281
290
|
#usageCacheEpoch = 0;
|
|
282
291
|
/** Raw broker credentials retained to size aggregate usage requests before account-pool filtering. */
|
|
283
292
|
#brokerUsageProviderByCredentialId = new Map<number, Provider>();
|
|
@@ -379,16 +388,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
379
388
|
const previousBlocksByKey = new Map<string, string>();
|
|
380
389
|
for (const entry of previous) {
|
|
381
390
|
for (const block of entry.blocks ?? []) {
|
|
382
|
-
previousBlocksByKey.set(
|
|
383
|
-
`${entry.id}\0${block.providerKey}\0${block.blockScope}`,
|
|
384
|
-
`${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`,
|
|
385
|
-
);
|
|
391
|
+
previousBlocksByKey.set(blockKey(entry.id, block), `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`);
|
|
386
392
|
}
|
|
387
393
|
}
|
|
388
394
|
const activeKeys = new Set<string>();
|
|
389
395
|
for (const entry of next) {
|
|
390
396
|
for (const block of entry.blocks ?? []) {
|
|
391
|
-
const key =
|
|
397
|
+
const key = blockKey(entry.id, block);
|
|
392
398
|
activeKeys.add(key);
|
|
393
399
|
const signature = `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`;
|
|
394
400
|
if (previousBlocksByKey.get(key) === signature) continue;
|
|
@@ -675,7 +681,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
675
681
|
|
|
676
682
|
getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
|
|
677
683
|
if (this.getCredentialBlock(credentialId, providerKey, blockScope) === undefined) return undefined;
|
|
678
|
-
return this.#credentialBlockReconcileAfter.get(
|
|
684
|
+
return this.#credentialBlockReconcileAfter.get(blockKey(credentialId, { providerKey, blockScope }));
|
|
679
685
|
}
|
|
680
686
|
|
|
681
687
|
listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
|
|
@@ -705,11 +711,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
705
711
|
this.#noteActivity();
|
|
706
712
|
this.#upsertSnapshotBlock(block);
|
|
707
713
|
this.#invalidateUsageCache();
|
|
714
|
+
const key = blockKey(block.credentialId, block);
|
|
708
715
|
this.#credentialBlockReconcileAfter.set(
|
|
709
|
-
|
|
716
|
+
key,
|
|
710
717
|
Math.min(block.blockedUntilMs, Date.now() + CREDENTIAL_BLOCK_RECONCILE_DELAY_MS),
|
|
711
718
|
);
|
|
712
719
|
const body = toCredentialBlockSnapshot(block);
|
|
720
|
+
const pending = this.#pendingCredentialBlocks.get(key);
|
|
721
|
+
if (!pending || pending.blockedUntilMs < body.blockedUntilMs) this.#pendingCredentialBlocks.set(key, body);
|
|
713
722
|
void this.#client
|
|
714
723
|
.upsertCredentialBlock(block.credentialId, body)
|
|
715
724
|
.then(() => {
|
|
@@ -725,14 +734,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
725
734
|
});
|
|
726
735
|
}
|
|
727
736
|
|
|
728
|
-
deleteCredentialBlock(
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
737
|
+
deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
|
|
738
|
+
this.#noteActivity();
|
|
739
|
+
const key = blockKey(credentialId, { providerKey, blockScope });
|
|
740
|
+
this.#pendingCredentialBlocks.delete(key);
|
|
741
|
+
const deleted = this.#snapshot.credentials
|
|
742
|
+
.find(entry => entry.id === credentialId)
|
|
743
|
+
?.blocks?.find(block => block.providerKey === providerKey && block.blockScope === blockScope);
|
|
744
|
+
if (deleted) this.#deletedCredentialBlocks.set(key, deleted);
|
|
745
|
+
this.#deleteSnapshotBlocks(credentialId, { providerKey, blockScope });
|
|
746
|
+
this.#credentialBlockReconcileAfter.delete(key);
|
|
747
|
+
this.#invalidateUsageCache();
|
|
748
|
+
void this.#client
|
|
749
|
+
.deleteCredentialBlock(credentialId, { providerKey, blockScope })
|
|
750
|
+
.then(() => {
|
|
751
|
+
this.#maybeRefreshSnapshot("credential block delete");
|
|
752
|
+
})
|
|
753
|
+
.catch(error => {
|
|
754
|
+
if (this.#deletedCredentialBlocks.get(key) === deleted) this.#deletedCredentialBlocks.delete(key);
|
|
755
|
+
this.#maybeRefreshSnapshot("credential block delete failed");
|
|
756
|
+
logger.warn("auth-broker credential block delete propagation failed", {
|
|
757
|
+
id: credentialId,
|
|
758
|
+
providerKey,
|
|
759
|
+
blockScope,
|
|
760
|
+
error: String(error),
|
|
761
|
+
});
|
|
762
|
+
});
|
|
732
763
|
}
|
|
733
764
|
|
|
734
765
|
deleteCredentialBlocks(credentialId: number): void {
|
|
735
766
|
this.#noteActivity();
|
|
767
|
+
for (const key of this.#pendingCredentialBlocks.keys()) {
|
|
768
|
+
if (key.startsWith(`${credentialId}\0`)) this.#pendingCredentialBlocks.delete(key);
|
|
769
|
+
}
|
|
736
770
|
this.#deleteSnapshotBlocks(credentialId);
|
|
737
771
|
for (const key of this.#credentialBlockReconcileAfter.keys()) {
|
|
738
772
|
if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
|
|
@@ -753,6 +787,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
753
787
|
|
|
754
788
|
cleanExpiredCredentialBlocks(nowMs: number): void {
|
|
755
789
|
this.#pruneExpiredCredentialBlocks(nowMs);
|
|
790
|
+
for (const [key, block] of this.#deletedCredentialBlocks) {
|
|
791
|
+
if (block.blockedUntilMs <= nowMs) this.#deletedCredentialBlocks.delete(key);
|
|
792
|
+
}
|
|
793
|
+
for (const [key, block] of this.#pendingCredentialBlocks) {
|
|
794
|
+
if (block.blockedUntilMs <= nowMs) this.#pendingCredentialBlocks.delete(key);
|
|
795
|
+
}
|
|
756
796
|
for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
|
|
757
797
|
if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
|
|
758
798
|
}
|
|
@@ -938,9 +978,29 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
938
978
|
}
|
|
939
979
|
|
|
940
980
|
#normalizeSnapshotEntryBlocks(entry: SnapshotEntry, nowMs: number): SnapshotEntry {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
981
|
+
const pending = this.#pendingBlocksFor(entry, nowMs);
|
|
982
|
+
if ((!entry.blocks || entry.blocks.length === 0) && pending.length === 0) return entry;
|
|
983
|
+
// A pending row replaces a shorter reported row for the same scope.
|
|
984
|
+
const merged = [
|
|
985
|
+
...(entry.blocks ?? []).filter(
|
|
986
|
+
block =>
|
|
987
|
+
!pending.some(
|
|
988
|
+
candidate => candidate.providerKey === block.providerKey && candidate.blockScope === block.blockScope,
|
|
989
|
+
),
|
|
990
|
+
),
|
|
991
|
+
...pending,
|
|
992
|
+
];
|
|
993
|
+
const blocks = merged
|
|
994
|
+
.filter(block => {
|
|
995
|
+
if (block.blockedUntilMs <= nowMs) return false;
|
|
996
|
+
const deleted = this.#deletedCredentialBlocks.get(blockKey(entry.id, block));
|
|
997
|
+
// Suppress only the exact row we deleted. A fresh block in this
|
|
998
|
+
// scope, including one with the same deadline but a newer update,
|
|
999
|
+
// remains authoritative while the DELETE propagates.
|
|
1000
|
+
return (
|
|
1001
|
+
!deleted || deleted.blockedUntilMs !== block.blockedUntilMs || deleted.updatedAtMs !== block.updatedAtMs
|
|
1002
|
+
);
|
|
1003
|
+
})
|
|
944
1004
|
.map(block => ({
|
|
945
1005
|
providerKey: block.providerKey,
|
|
946
1006
|
blockScope: block.blockScope,
|
|
@@ -954,7 +1014,32 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
954
1014
|
return next;
|
|
955
1015
|
}
|
|
956
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Locally written blocks the incoming entry does not yet carry. The broker
|
|
1019
|
+
* write is asynchronous: a snapshot racing it, or a failed write, must not
|
|
1020
|
+
* erase a block this process still depends on. A pending row retires once the
|
|
1021
|
+
* broker reports it (or a longer deadline) or when it expires.
|
|
1022
|
+
*/
|
|
1023
|
+
#pendingBlocksFor(entry: SnapshotEntry, nowMs: number): CredentialBlockSnapshot[] {
|
|
1024
|
+
if (this.#pendingCredentialBlocks.size === 0) return [];
|
|
1025
|
+
const pending: CredentialBlockSnapshot[] = [];
|
|
1026
|
+
const prefix = `${entry.id}\0`;
|
|
1027
|
+
for (const [key, block] of this.#pendingCredentialBlocks) {
|
|
1028
|
+
if (!key.startsWith(prefix)) continue;
|
|
1029
|
+
const reported = entry.blocks?.find(
|
|
1030
|
+
candidate => candidate.providerKey === block.providerKey && candidate.blockScope === block.blockScope,
|
|
1031
|
+
);
|
|
1032
|
+
if (block.blockedUntilMs <= nowMs || (reported && reported.blockedUntilMs >= block.blockedUntilMs)) {
|
|
1033
|
+
this.#pendingCredentialBlocks.delete(key);
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
pending.push(block);
|
|
1037
|
+
}
|
|
1038
|
+
return pending;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
957
1041
|
#upsertSnapshotBlock(block: StoredCredentialBlock): void {
|
|
1042
|
+
this.#deletedCredentialBlocks.delete(blockKey(block.credentialId, block));
|
|
958
1043
|
const index = this.#snapshot.credentials.findIndex(entry => entry.id === block.credentialId);
|
|
959
1044
|
if (index === -1) return;
|
|
960
1045
|
const entry = this.#snapshot.credentials[index]!;
|
|
@@ -978,13 +1063,20 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
978
1063
|
this.#snapshot = { ...this.#snapshot, credentials };
|
|
979
1064
|
}
|
|
980
1065
|
|
|
981
|
-
#deleteSnapshotBlocks(credentialId: number): void {
|
|
1066
|
+
#deleteSnapshotBlocks(credentialId: number, target?: { providerKey: string; blockScope: string }): void {
|
|
982
1067
|
const index = this.#snapshot.credentials.findIndex(entry => entry.id === credentialId);
|
|
983
1068
|
if (index === -1) return;
|
|
984
1069
|
const entry = this.#snapshot.credentials[index]!;
|
|
985
1070
|
if (!entry.blocks || entry.blocks.length === 0) return;
|
|
1071
|
+
const blocks = target
|
|
1072
|
+
? entry.blocks.filter(
|
|
1073
|
+
block => block.providerKey !== target.providerKey || block.blockScope !== target.blockScope,
|
|
1074
|
+
)
|
|
1075
|
+
: [];
|
|
1076
|
+
if (blocks.length === entry.blocks.length) return;
|
|
986
1077
|
const next: SnapshotEntry = { ...entry };
|
|
987
|
-
|
|
1078
|
+
if (blocks.length > 0) next.blocks = blocks;
|
|
1079
|
+
else delete next.blocks;
|
|
988
1080
|
const credentials = [...this.#snapshot.credentials];
|
|
989
1081
|
credentials[index] = next;
|
|
990
1082
|
this.#snapshot = { ...this.#snapshot, credentials };
|
|
@@ -1050,14 +1142,22 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1050
1142
|
async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
|
|
1051
1143
|
this.#noteActivity();
|
|
1052
1144
|
this.#invalidateUsageCache();
|
|
1053
|
-
|
|
1145
|
+
try {
|
|
1146
|
+
await this.#client.notifyUsageStale(signal);
|
|
1147
|
+
} catch (err) {
|
|
1054
1148
|
logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
|
|
1055
|
-
}
|
|
1149
|
+
} finally {
|
|
1150
|
+
// A concurrent read may have reached the broker before it processed the
|
|
1151
|
+
// notification. That response cannot seed the post-notification cache.
|
|
1152
|
+
this.#invalidateUsageCache();
|
|
1153
|
+
}
|
|
1056
1154
|
}
|
|
1057
1155
|
|
|
1058
1156
|
#invalidateUsageCache(): void {
|
|
1059
|
-
|
|
1060
|
-
|
|
1157
|
+
// Snapshot/block updates invalidate quota evidence, not a failed broker
|
|
1158
|
+
// connection's cooldown. Keep the flight too: its completion will queue
|
|
1159
|
+
// one current-generation successor instead of overlapping broker calls.
|
|
1160
|
+
if (this.#usageCache?.reports !== null) this.#usageCache = undefined;
|
|
1061
1161
|
this.#usageCacheEpoch += 1;
|
|
1062
1162
|
}
|
|
1063
1163
|
|
|
@@ -1102,9 +1202,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1102
1202
|
|
|
1103
1203
|
/**
|
|
1104
1204
|
* Store-level hook consumed by `AuthStorage.usage.reports()` — proxies
|
|
1105
|
-
* to the broker's `/v1/usage` endpoint.
|
|
1106
|
-
*
|
|
1107
|
-
* residential laptop is, so all credentials surface every cycle.
|
|
1205
|
+
* to the broker's `/v1/usage` endpoint. Shared per-credential caches and
|
|
1206
|
+
* cooldowns keep separate clients from multiplying provider probes.
|
|
1108
1207
|
*/
|
|
1109
1208
|
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
1110
1209
|
this.#noteActivity();
|
|
@@ -1119,8 +1218,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1119
1218
|
* same response (coalesced + cached), then overlays any client-observed
|
|
1120
1219
|
* header hints for the matching credential.
|
|
1121
1220
|
*
|
|
1122
|
-
* The broker
|
|
1123
|
-
*
|
|
1221
|
+
* The broker caches each credential independently; the short client TTL
|
|
1222
|
+
* also folds sequential consumers into one broker round-trip.
|
|
1124
1223
|
*/
|
|
1125
1224
|
async getUsageReport(
|
|
1126
1225
|
provider: Provider,
|
|
@@ -1280,7 +1379,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1280
1379
|
const inflight = this.#client
|
|
1281
1380
|
.fetchUsage({ maxAccountsPerProvider: this.#maxBrokerUsageAccounts() })
|
|
1282
1381
|
.then(body => {
|
|
1283
|
-
if (epoch !== this.#usageCacheEpoch)
|
|
1382
|
+
if (epoch !== this.#usageCacheEpoch) {
|
|
1383
|
+
if (this.#usageInflight === inflight) this.#usageInflight = undefined;
|
|
1384
|
+
return this.#loadUsageReports();
|
|
1385
|
+
}
|
|
1284
1386
|
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
|
1285
1387
|
return body.reports;
|
|
1286
1388
|
})
|
|
@@ -1289,7 +1391,6 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1289
1391
|
// Documented 15s TTL fallback: cache the null so sequential callers
|
|
1290
1392
|
// don't re-hit the broker while it's still down. See
|
|
1291
1393
|
// docs/auth-broker-gateway.md § "Client-side single-flight".
|
|
1292
|
-
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
1293
1394
|
this.#usageCache = { reports: null, fetchedAt: Date.now() };
|
|
1294
1395
|
return null;
|
|
1295
1396
|
})
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
} from "./types";
|
|
44
44
|
import {
|
|
45
45
|
clientUsageReportRequestSchema,
|
|
46
|
+
credentialBlockDeleteRequestSchema,
|
|
46
47
|
credentialBlockRequestSchema,
|
|
47
48
|
credentialDisableRequestSchema,
|
|
48
49
|
credentialUploadRequestSchema,
|
|
@@ -832,6 +833,25 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
|
|
|
832
833
|
return json(status, { error: message });
|
|
833
834
|
}
|
|
834
835
|
}
|
|
836
|
+
const blockDeleteMatch = req.method === "DELETE" ? pathname.match(BLOCK_ROUTE) : null;
|
|
837
|
+
if (blockDeleteMatch) {
|
|
838
|
+
const id = Number.parseInt(blockDeleteMatch[1], 10);
|
|
839
|
+
const parsed = await parseBody(req, credentialBlockDeleteRequestSchema);
|
|
840
|
+
if (!parsed.ok) return parsed.response;
|
|
841
|
+
if (!opts.storage.credentials.snapshot().credentials.some(entry => entry.id === id)) {
|
|
842
|
+
return json(404, { error: `No credential with id=${id}` });
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
opts.storage.blocks.delete(id, parsed.data.providerKey, parsed.data.blockScope);
|
|
846
|
+
const response: CredentialBlocksDeleteResponse = { ok: true };
|
|
847
|
+
logger.info("auth-broker credential block deleted", { id, peer, ...parsed.data });
|
|
848
|
+
return json(200, response);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
851
|
+
logger.warn("auth-broker credential block delete failed", { id, peer, error: message });
|
|
852
|
+
return json(500, { error: message });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
835
855
|
const blocksDeleteMatch = req.method === "DELETE" ? pathname.match(BLOCKS_ROUTE) : null;
|
|
836
856
|
if (blocksDeleteMatch) {
|
|
837
857
|
const id = Number.parseInt(blocksDeleteMatch[1], 10);
|
package/src/auth-broker/types.ts
CHANGED
|
@@ -96,12 +96,15 @@ export interface DisabledCredentialsResponse {
|
|
|
96
96
|
/** POST /v1/credential/:id/block request body. */
|
|
97
97
|
export type CredentialBlockRequest = CredentialBlockSnapshot;
|
|
98
98
|
|
|
99
|
+
/** DELETE /v1/credential/:id/block request body; empty scope targets only the global row. */
|
|
100
|
+
export type CredentialBlockDeleteRequest = Pick<CredentialBlockSnapshot, "providerKey" | "blockScope">;
|
|
101
|
+
|
|
99
102
|
/** POST /v1/credential/:id/block response body. */
|
|
100
103
|
export interface CredentialBlockResponse {
|
|
101
104
|
ok: boolean;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
/** DELETE /v1/credential/:id/blocks response body. */
|
|
107
|
+
/** DELETE /v1/credential/:id/block or /blocks response body. */
|
|
105
108
|
export interface CredentialBlocksDeleteResponse {
|
|
106
109
|
ok: boolean;
|
|
107
110
|
}
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ClientUsageReportRequest,
|
|
28
28
|
ClientUsageReportResponse,
|
|
29
29
|
ClientUsageSummaryResponse,
|
|
30
|
+
CredentialBlockDeleteRequest,
|
|
30
31
|
CredentialBlockRequest,
|
|
31
32
|
CredentialBlockResponse,
|
|
32
33
|
CredentialBlockSnapshot,
|
|
@@ -393,6 +394,12 @@ export const disabledCredentialsResponseSchema: FluentType<DisabledCredentialsRe
|
|
|
393
394
|
|
|
394
395
|
export const credentialBlockRequestSchema: FluentType<CredentialBlockRequest> = credentialBlockSnapshotSchema;
|
|
395
396
|
|
|
397
|
+
export const credentialBlockDeleteRequestSchema: FluentType<CredentialBlockDeleteRequest> = type({
|
|
398
|
+
"+": "reject",
|
|
399
|
+
providerKey: type("string").atLeastLength(1),
|
|
400
|
+
blockScope: "string",
|
|
401
|
+
});
|
|
402
|
+
|
|
396
403
|
export const credentialBlockResponseSchema: FluentType<CredentialBlockResponse> = type({
|
|
397
404
|
"+": "reject",
|
|
398
405
|
ok: "boolean",
|
package/src/auth-retry.ts
CHANGED
|
@@ -18,10 +18,10 @@ import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-lim
|
|
|
18
18
|
* - `error !== undefined && lastChance` → **step (c): switch account**
|
|
19
19
|
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
20
20
|
*
|
|
21
|
-
* Current drivers
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* cycles, or hits {@link AUTH_RETRY_MAX_ATTEMPTS}.
|
|
21
|
+
* Current drivers give an ordinary 401/auth failure one step (b) before
|
|
22
|
+
* repeating step (c) through distinct siblings. Account-scoped policy denials,
|
|
23
|
+
* 403s, and usage-limit failures skip refresh. Rotation stops when the resolver
|
|
24
|
+
* returns `undefined`, cycles, or hits {@link AUTH_RETRY_MAX_ATTEMPTS}.
|
|
25
25
|
*/
|
|
26
26
|
export interface ApiKeyResolveContext {
|
|
27
27
|
/** True when the resolver should rotate to a sibling credential. */
|
|
@@ -42,6 +42,12 @@ export interface ResolvedApiKey {
|
|
|
42
42
|
apiKey: string;
|
|
43
43
|
/** Durable row id of the credential that supplied this bearer, when known. */
|
|
44
44
|
credentialId?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Resolved after `LimitsApi.rotate` slept out a sibling's short block
|
|
47
|
+
* (`afterSiblingWait`): the driver accepts this bearer even if the request
|
|
48
|
+
* already sent it before that block.
|
|
49
|
+
*/
|
|
50
|
+
afterSiblingWait?: boolean;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export type ApiKeyResolution = string | ResolvedApiKey | undefined;
|
|
@@ -53,6 +59,19 @@ export function resolvedApiKeyBearer(resolved: ApiKeyResolution): string | undef
|
|
|
53
59
|
return (typeof resolved === "string" ? resolved : resolved?.apiKey) || undefined;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Mark a post-rotation resolution as following a sibling-unblock wait so the
|
|
64
|
+
* retry driver may resend a bearer it already tried. Used by the rotating
|
|
65
|
+
* resolvers (`KeyCascade.resolver`, coding-agent `createApiKeyResolver`).
|
|
66
|
+
*/
|
|
67
|
+
export function markAfterSiblingWait(resolved: ApiKeyResolution): ApiKeyResolution {
|
|
68
|
+
const apiKey = resolvedApiKeyBearer(resolved);
|
|
69
|
+
if (apiKey === undefined) return resolved;
|
|
70
|
+
return typeof resolved === "string"
|
|
71
|
+
? { apiKey, afterSiblingWait: true }
|
|
72
|
+
: { ...resolved, apiKey, afterSiblingWait: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
/** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
|
|
57
76
|
export type ApiKey = string | ApiKeyResolver;
|
|
58
77
|
|
|
@@ -105,9 +124,8 @@ export function seedApiKeyResolver(seed: ApiKeyResolution, resolver: ApiKeyResol
|
|
|
105
124
|
export { isAuthRetryableError };
|
|
106
125
|
|
|
107
126
|
/**
|
|
108
|
-
* Legacy
|
|
109
|
-
* `false` → refresh-same, `true` → rotate/switch. Current drivers
|
|
110
|
-
* once for ordinary 401/auth failures; usage/account-limit failures may repeat
|
|
127
|
+
* Legacy a/b/c retry sequence retained for public compatibility:
|
|
128
|
+
* `false` → refresh-same, `true` → rotate/switch. Current drivers may repeat
|
|
111
129
|
* sibling rotation until a termination guard fires.
|
|
112
130
|
*/
|
|
113
131
|
export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
|
|
@@ -155,8 +173,6 @@ export interface AuthRetryKeyState {
|
|
|
155
173
|
lastKey: string;
|
|
156
174
|
/** Whether the current credential already consumed its 401 refresh-same retry. */
|
|
157
175
|
refreshedCurrent: boolean;
|
|
158
|
-
/** Whether the legacy non-usage auth path already switched to one sibling. */
|
|
159
|
-
legacyAuthSwitchUsed: boolean;
|
|
160
176
|
/** Whether this operation already replayed once after an explicit token-refresh request. */
|
|
161
177
|
tokenRefreshReplayUsed?: boolean;
|
|
162
178
|
/** Total outbound attempts accepted for this logical operation, including the initial request. */
|
|
@@ -168,14 +184,20 @@ export function createAuthRetryKeyState(initialKey: string): AuthRetryKeyState {
|
|
|
168
184
|
attemptedKeys: new Set([initialKey]),
|
|
169
185
|
lastKey: initialKey,
|
|
170
186
|
refreshedCurrent: false,
|
|
171
|
-
legacyAuthSwitchUsed: false,
|
|
172
187
|
tokenRefreshReplayUsed: false,
|
|
173
188
|
attempts: 1,
|
|
174
189
|
};
|
|
175
190
|
}
|
|
176
191
|
|
|
177
|
-
function acceptRetryKey(
|
|
178
|
-
|
|
192
|
+
function acceptRetryKey(
|
|
193
|
+
state: AuthRetryKeyState,
|
|
194
|
+
key: string,
|
|
195
|
+
refreshedCurrent: boolean,
|
|
196
|
+
afterSiblingWait = false,
|
|
197
|
+
): string | undefined {
|
|
198
|
+
if ((!afterSiblingWait && state.attemptedKeys.has(key)) || state.attempts >= AUTH_RETRY_MAX_ATTEMPTS) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
179
201
|
state.attemptedKeys.add(key);
|
|
180
202
|
state.attempts += 1;
|
|
181
203
|
state.lastKey = key;
|
|
@@ -202,7 +224,6 @@ export async function resolveNextAuthRetryKey(
|
|
|
202
224
|
}
|
|
203
225
|
const directRotation = isDirectCredentialRotationError(error);
|
|
204
226
|
if (!directRotation) {
|
|
205
|
-
if (state.legacyAuthSwitchUsed) return undefined;
|
|
206
227
|
if (!state.refreshedCurrent) {
|
|
207
228
|
const refreshed = await resolveRetryKey(resolver, false, error, signal, state.lastKey, onResolved);
|
|
208
229
|
state.refreshedCurrent = true;
|
|
@@ -215,11 +236,13 @@ export async function resolveNextAuthRetryKey(
|
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
if (signal?.aborted) return undefined;
|
|
218
|
-
|
|
239
|
+
let afterSiblingWait = false;
|
|
240
|
+
const rotated = await resolveRetryKey(resolver, true, error, signal, state.lastKey, resolved => {
|
|
241
|
+
afterSiblingWait = typeof resolved === "object" && resolved.afterSiblingWait === true;
|
|
242
|
+
onResolved?.(resolved);
|
|
243
|
+
});
|
|
219
244
|
if (signal?.aborted || rotated === undefined) return undefined;
|
|
220
|
-
|
|
221
|
-
if (accepted !== undefined && !directRotation) state.legacyAuthSwitchUsed = true;
|
|
222
|
-
return accepted;
|
|
245
|
+
return acceptRetryKey(state, rotated, !directRotation, afterSiblingWait);
|
|
223
246
|
}
|
|
224
247
|
|
|
225
248
|
function oauthCredentialIdentity(access: OAuthAccess): string {
|
|
@@ -248,9 +271,9 @@ async function runOAuthAttempt<T>(
|
|
|
248
271
|
* applicable policy is exhausted, the resolver declines or cycles, or the
|
|
249
272
|
* operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. An explicit typed
|
|
250
273
|
* token-refresh request gets exactly one refresh-current replay and never
|
|
251
|
-
* enters sibling rotation. Ordinary 401/auth failures
|
|
252
|
-
*
|
|
253
|
-
*
|
|
274
|
+
* enters sibling rotation. Ordinary 401/auth failures get one refresh-same,
|
|
275
|
+
* then rotate through distinct siblings; 403/usage-limit failures skip the
|
|
276
|
+
* refresh and rotate directly.
|
|
254
277
|
*
|
|
255
278
|
* Used by non-streaming consumers (image generation, web search, completion
|
|
256
279
|
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
@@ -331,7 +354,7 @@ export interface WithOAuthAccessOptions {
|
|
|
331
354
|
* - initial → `getOAuthAccess` (or `opts.seed`).
|
|
332
355
|
* - typed token-refresh request → one forced refresh-current replay, then stop.
|
|
333
356
|
* - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
|
|
334
|
-
* current account, then sibling rotation.
|
|
357
|
+
* current account, then sibling rotation through distinct credentials.
|
|
335
358
|
* - 403/usage-limit failure → `rotateSessionCredential` directly, without a
|
|
336
359
|
* force-refresh detour.
|
|
337
360
|
*
|
|
@@ -363,7 +386,6 @@ export async function withOAuthAccess<T>(
|
|
|
363
386
|
const attemptedBearers = new Set([lastAccess.accessToken]);
|
|
364
387
|
const attemptedCredentialIdentities = new Set([oauthCredentialIdentity(lastAccess)]);
|
|
365
388
|
let attemptCount = 1;
|
|
366
|
-
let legacyAuthSwitchUsed = false;
|
|
367
389
|
let refreshedCurrent = false;
|
|
368
390
|
let tokenRefreshReplayUsed = false;
|
|
369
391
|
let attemptResult = await runOAuthAttempt(lastAccess, attempt, isAuthError);
|
|
@@ -398,7 +420,6 @@ export async function withOAuthAccess<T>(
|
|
|
398
420
|
|
|
399
421
|
const directRotation = isDirectCredentialRotationError(lastError);
|
|
400
422
|
if (!directRotation) {
|
|
401
|
-
if (legacyAuthSwitchUsed) break;
|
|
402
423
|
if (!refreshedCurrent) {
|
|
403
424
|
refreshedCurrent = true;
|
|
404
425
|
try {
|
|
@@ -424,14 +445,16 @@ export async function withOAuthAccess<T>(
|
|
|
424
445
|
}
|
|
425
446
|
|
|
426
447
|
if (signal?.aborted || attemptCount >= AUTH_RETRY_MAX_ATTEMPTS) break;
|
|
448
|
+
let afterSiblingWait = false;
|
|
427
449
|
try {
|
|
428
|
-
const
|
|
450
|
+
const rotation = await storage.limits.rotate(provider, sessionId, {
|
|
429
451
|
error: lastError,
|
|
430
452
|
signal,
|
|
431
453
|
apiKey: lastAccess.accessToken,
|
|
432
454
|
credentialId: lastAccess.credentialId,
|
|
433
455
|
});
|
|
434
|
-
if (!
|
|
456
|
+
if (!rotation.switched) break;
|
|
457
|
+
afterSiblingWait = rotation.afterSiblingWait === true;
|
|
435
458
|
next = await storage.oauth.access(provider, sessionId, { signal });
|
|
436
459
|
} catch {
|
|
437
460
|
next = undefined;
|
|
@@ -439,8 +462,8 @@ export async function withOAuthAccess<T>(
|
|
|
439
462
|
if (signal?.aborted || !next) break;
|
|
440
463
|
const credentialIdentity = oauthCredentialIdentity(next);
|
|
441
464
|
if (
|
|
442
|
-
|
|
443
|
-
|
|
465
|
+
(!afterSiblingWait &&
|
|
466
|
+
(attemptedCredentialIdentities.has(credentialIdentity) || attemptedBearers.has(next.accessToken))) ||
|
|
444
467
|
attemptCount >= AUTH_RETRY_MAX_ATTEMPTS
|
|
445
468
|
) {
|
|
446
469
|
break;
|
|
@@ -450,7 +473,6 @@ export async function withOAuthAccess<T>(
|
|
|
450
473
|
attemptCount += 1;
|
|
451
474
|
lastAccess = next;
|
|
452
475
|
refreshedCurrent = !directRotation;
|
|
453
|
-
if (!directRotation) legacyAuthSwitchUsed = true;
|
|
454
476
|
attemptResult = await runOAuthAttempt(next, attempt, isAuthError);
|
|
455
477
|
if (attemptResult.ok) return attemptResult.result;
|
|
456
478
|
lastError = attemptResult.error;
|
|
@@ -20,11 +20,15 @@ const IMAGE_SYSTEM_INSTRUCTION =
|
|
|
20
20
|
interface HostedOutput {
|
|
21
21
|
type: "image_generation_call" | "message";
|
|
22
22
|
result?: string;
|
|
23
|
+
size?: string;
|
|
24
|
+
quality?: string;
|
|
23
25
|
content?: Array<{ type?: string; text?: string; refusal?: string }>;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface HostedResponse {
|
|
27
29
|
output?: HostedOutput[];
|
|
30
|
+
/** Resolved tool configuration echoed by the backend, including the image model that actually ran. */
|
|
31
|
+
tools?: Array<{ type?: string; model?: string }>;
|
|
28
32
|
usage?: { input_tokens?: number; output_tokens?: number };
|
|
29
33
|
error?: { message?: string };
|
|
30
34
|
}
|
|
@@ -83,7 +87,12 @@ function collectResponse(response: HostedResponse): ImageGenerationResult {
|
|
|
83
87
|
for (const output of response.output ?? []) {
|
|
84
88
|
if (output.type === "image_generation_call" && output.result) {
|
|
85
89
|
const bytes = Buffer.from(output.result, "base64");
|
|
86
|
-
images.push({
|
|
90
|
+
images.push({
|
|
91
|
+
data: output.result,
|
|
92
|
+
mimeType: parseImageMetadata(bytes)?.mimeType ?? "image/webp",
|
|
93
|
+
...(output.size ? { size: output.size } : {}),
|
|
94
|
+
...(output.quality ? { quality: output.quality } : {}),
|
|
95
|
+
});
|
|
87
96
|
}
|
|
88
97
|
if (output.type === "message") {
|
|
89
98
|
for (const part of output.content ?? []) {
|
|
@@ -93,7 +102,13 @@ function collectResponse(response: HostedResponse): ImageGenerationResult {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
const text = texts.join("\n").trim();
|
|
96
|
-
|
|
105
|
+
const model = response.tools?.find(tool => tool.type === "image_generation")?.model;
|
|
106
|
+
return {
|
|
107
|
+
images,
|
|
108
|
+
...(text ? { text } : {}),
|
|
109
|
+
usage: usageFromWire(response.usage),
|
|
110
|
+
...(model ? { model } : {}),
|
|
111
|
+
};
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
async function parseSse(response: Response, signal?: AbortSignal): Promise<ImageGenerationResult> {
|
|
@@ -116,7 +131,7 @@ async function parseSse(response: Response, signal?: AbortSignal): Promise<Image
|
|
|
116
131
|
completed = event.response;
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
|
-
return collectResponse(completed?.output?.length ? completed : { output: fallbackOutput
|
|
134
|
+
return collectResponse(completed?.output?.length ? completed : { ...completed, output: fallbackOutput });
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
export async function generateHostedImage(
|
package/src/images/types.ts
CHANGED
|
@@ -17,12 +17,21 @@ export interface ImageGenerationRequest {
|
|
|
17
17
|
export interface GeneratedImage {
|
|
18
18
|
data: string;
|
|
19
19
|
mimeType: string;
|
|
20
|
+
/** Output dimensions (`WIDTHxHEIGHT`) the provider reports for this image, which may differ from the request. */
|
|
21
|
+
size?: string;
|
|
22
|
+
/** Rendering quality the provider reports for this image. */
|
|
23
|
+
quality?: string;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export interface ImageGenerationResult {
|
|
23
27
|
images: GeneratedImage[];
|
|
24
28
|
text?: string;
|
|
25
29
|
usage: Usage;
|
|
30
|
+
/**
|
|
31
|
+
* Image model the provider reports having run. Hosted backends may substitute their own model for the
|
|
32
|
+
* selected catalog entry (the ChatGPT/Codex backend does), so callers should prefer this over the catalog id.
|
|
33
|
+
*/
|
|
34
|
+
model?: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
export interface ImageGenerationOptions {
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export type * from "./providers/openai-completions";
|
|
|
39
39
|
export type * from "./providers/openai-responses";
|
|
40
40
|
export type * from "./providers/synthetic";
|
|
41
41
|
export * from "./registry";
|
|
42
|
+
export { resolveCacheRetention } from "./utils";
|
|
42
43
|
export * from "./stream";
|
|
43
44
|
export * from "./types";
|
|
44
45
|
export * from "./usage";
|
package/src/judgment/types.ts
CHANGED
|
@@ -117,15 +117,19 @@ export class JudgmentParseError extends Error {
|
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
119
|
* Usage from a backend that reports token counts and, optionally, one billed
|
|
120
|
-
* USD amount.
|
|
120
|
+
* USD amount. Missing or non-finite amounts contribute zero to session totals.
|
|
121
|
+
* Judgment pricing is input-only, so the amount lands on `input`.
|
|
121
122
|
*/
|
|
122
|
-
export function tokenUsage(input: number, output: number, cost = 0): Usage {
|
|
123
|
+
export function tokenUsage(input: number | undefined, output: number | undefined, cost = 0): Usage {
|
|
124
|
+
const inputTokens = typeof input === "number" && Number.isFinite(input) ? input : 0;
|
|
125
|
+
const outputTokens = typeof output === "number" && Number.isFinite(output) ? output : 0;
|
|
126
|
+
const billedCost = Number.isFinite(cost) ? cost : 0;
|
|
123
127
|
return {
|
|
124
|
-
input,
|
|
125
|
-
output,
|
|
128
|
+
input: inputTokens,
|
|
129
|
+
output: outputTokens,
|
|
126
130
|
cacheRead: 0,
|
|
127
131
|
cacheWrite: 0,
|
|
128
|
-
totalTokens:
|
|
129
|
-
cost: { input:
|
|
132
|
+
totalTokens: inputTokens + outputTokens,
|
|
133
|
+
cost: { input: billedCost, output: 0, cacheRead: 0, cacheWrite: 0, total: billedCost },
|
|
130
134
|
};
|
|
131
135
|
}
|