@oh-my-pi/pi-ai 18.7.0 → 18.8.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 +22 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +1 -0
- package/dist/types/auth/store.d.ts +6 -0
- package/dist/types/auth-broker/remote-store.d.ts +1 -0
- package/dist/types/auth-broker/snapshot-cache.d.ts +12 -0
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/dialect/terminator-wait.d.ts +19 -0
- package/dist/types/env-api-key.d.ts +21 -0
- package/dist/types/images/shared.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/connect-frame.d.ts +44 -0
- package/dist/types/providers/cursor/interaction-query.d.ts +0 -2
- package/dist/types/stream.d.ts +0 -21
- package/dist/types/transcription/types.d.ts +2 -1
- package/dist/types/utils/aws-profile.d.ts +5 -0
- package/package.json +6 -6
- package/src/auth/affinity.ts +33 -24
- package/src/auth/blocks.ts +90 -14
- package/src/auth/cascade.ts +1 -1
- package/src/auth/select.ts +27 -2
- package/src/auth/sqlite-credential-store.ts +85 -42
- package/src/auth/store.ts +6 -0
- package/src/auth/usage-cache.ts +16 -5
- package/src/auth/usage.ts +1 -1
- package/src/auth-broker/discover.ts +5 -4
- package/src/auth-broker/remote-store.ts +117 -46
- package/src/auth-broker/server.ts +283 -123
- package/src/auth-broker/snapshot-cache.ts +142 -2
- package/src/auth-gateway/server.ts +19 -5
- package/src/auth-gateway/session-state.ts +51 -9
- package/src/dialect/deepseek.ts +10 -2
- package/src/dialect/gemini.ts +8 -2
- package/src/dialect/hermes.ts +8 -2
- package/src/dialect/kimi.ts +8 -2
- package/src/dialect/qwen-xml.ts +8 -2
- package/src/dialect/qwen3.ts +8 -2
- package/src/dialect/terminator-wait.ts +42 -0
- package/src/env-api-key.ts +78 -0
- package/src/images/openai-hosted.ts +11 -4
- package/src/images/openai-images.ts +19 -13
- package/src/images/shared.ts +10 -2
- package/src/index.ts +1 -0
- package/src/providers/anthropic.ts +39 -13
- package/src/providers/apple-foundation-models.ts +10 -4
- package/src/providers/azure-openai-responses.ts +1 -1
- package/src/providers/connect-frame.ts +107 -0
- package/src/providers/cursor/interaction-query.ts +1 -9
- package/src/providers/cursor.ts +87 -47
- package/src/providers/devin.ts +32 -41
- package/src/providers/gitlab-duo-workflow.ts +5 -2
- package/src/providers/google.ts +1 -1
- package/src/providers/ollama.ts +1 -1
- package/src/providers/openai-codex-responses.ts +26 -18
- package/src/providers/openai-completions.ts +1 -1
- package/src/providers/openai-responses.ts +5 -4
- package/src/providers/openai-shared.ts +53 -18
- package/src/providers/transcriptions-server.ts +1 -1
- package/src/providers/transform-messages.ts +17 -13
- package/src/registry/aws.ts +51 -14
- package/src/registry/cloudflare-ai-gateway.ts +24 -1
- package/src/stream.ts +3 -72
- package/src/transcription/types.ts +2 -1
- package/src/utils/aws-profile.ts +10 -4
- package/src/utils/thinking-loop.ts +18 -3
- package/src/utils.ts +22 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.0] - 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- The environment API-key helpers (`getEnvApiKey`, `getEnvApiKeyName`, and `listProvidersWithEnvKey`) are no longer exported from `@oh-my-pi/pi-ai/stream`; import them from `@oh-my-pi/pi-ai` or `@oh-my-pi/pi-ai/env-api-key` instead.
|
|
10
|
+
- `TranscriptionRequest.audio` now accepts `Uint8Array | Blob`. Consumers must handle `Blob` values when reading transcription requests.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Improved CPU and memory efficiency across streamed model responses, including Cursor, Devin, Codex, OpenAI Responses, and GitLab Duo Workflow.
|
|
15
|
+
- Improved request and authentication performance, including account ranking, OAuth preflight, credential rate-limit checks, credential synchronization, and auth gateway requests.
|
|
16
|
+
- Improved tool-call parsing performance for long calls and Apple Foundation Models requests.
|
|
17
|
+
- Improved Cloudflare AI Gateway request performance and AWS credential-source detection.
|
|
18
|
+
- Reduced memory usage when handling generated images and usage reports.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed false thinking-loop detections for Gemini, DeepSeek, and Grok when responses contain repetitive code or markup such as VRML, SVG, or JSON; valid output is no longer discarded and retried.
|
|
23
|
+
- Fixed the Cursor provider retaining request resources after requests completed.
|
|
24
|
+
- Fixed session-affinity pins growing without bound in long-lived gateways; pins are now capped at 256 sessions per provider.
|
|
25
|
+
- Fixed Anthropic sessions failing every request with HTTP 400 ("role 'system' must precede an 'assistant' message") after a tool change coincided with compaction or an interrupted or failed reply; sessions already stuck this way recover on the next message ([#14746](https://github.com/can1357/oh-my-pi/issues/14746)).
|
|
26
|
+
|
|
5
27
|
## [18.7.0] - 2026-10-06
|
|
6
28
|
|
|
7
29
|
### Added
|
|
@@ -60,6 +60,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
60
60
|
deleteCachePrefix(prefix: string): void;
|
|
61
61
|
cleanExpiredCache(): void;
|
|
62
62
|
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
|
|
63
|
+
getCredentialBlockScopes(credentialId: number, providerKey: string): Map<string, number>;
|
|
63
64
|
getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
|
|
64
65
|
upsertCredentialBlock(block: StoredCredentialBlock): void;
|
|
65
66
|
deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void;
|
|
@@ -71,6 +71,12 @@ export interface CredentialCacheStore {
|
|
|
71
71
|
export interface CredentialBlockStore {
|
|
72
72
|
/** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
|
|
73
73
|
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Non-expired blocks for one credential and providerKey, keyed by block
|
|
76
|
+
* scope (`""` is unscoped), in one read. Optional: selectors fall back to
|
|
77
|
+
* {@link CredentialBlockStore.getCredentialBlock} per scope when absent.
|
|
78
|
+
*/
|
|
79
|
+
getCredentialBlockScopes?(credentialId: number, providerKey: string): ReadonlyMap<string, number>;
|
|
74
80
|
/** Earliest time a shared-store block should be eligible for live-usage reconciliation. */
|
|
75
81
|
getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
|
|
76
82
|
/** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */
|
|
@@ -73,6 +73,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
73
73
|
/** Broker-backed disabled tombstones; empty against brokers predating the endpoint. */
|
|
74
74
|
listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]>;
|
|
75
75
|
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
|
|
76
|
+
getCredentialBlockScopes(credentialId: number, providerKey: string): Map<string, number>;
|
|
76
77
|
getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
|
|
77
78
|
listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[];
|
|
78
79
|
upsertCredentialBlock(block: StoredCredentialBlock): void;
|
|
@@ -15,3 +15,15 @@ export interface WriteAuthBrokerSnapshotCacheOptions {
|
|
|
15
15
|
}
|
|
16
16
|
export declare function readAuthBrokerSnapshotCache(opts: ReadAuthBrokerSnapshotCacheOptions): Promise<SnapshotResponse | null>;
|
|
17
17
|
export declare function writeAuthBrokerSnapshotCache(opts: WriteAuthBrokerSnapshotCacheOptions): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Fire-and-forget cache write for snapshot callbacks. The first snapshot after
|
|
20
|
+
* a quiet period is written immediately; later ones within
|
|
21
|
+
* {@link WRITE_COALESCE_MS} collapse into a single trailing write of the
|
|
22
|
+
* newest. A snapshot whose generation and credential content match the last
|
|
23
|
+
* write is skipped (see {@link UNCHANGED_REWRITE_MS}). Pending writes are
|
|
24
|
+
* flushed on process shutdown and by {@link flushAuthBrokerSnapshotCacheWrites};
|
|
25
|
+
* failures are logged, never thrown.
|
|
26
|
+
*/
|
|
27
|
+
export declare function scheduleAuthBrokerSnapshotCacheWrite(opts: WriteAuthBrokerSnapshotCacheOptions): void;
|
|
28
|
+
/** Write every pending scheduled snapshot now and wait for in-flight writes to settle. */
|
|
29
|
+
export declare function flushAuthBrokerSnapshotCacheWrites(): Promise<void>;
|
|
@@ -63,6 +63,12 @@ export interface AuthGatewaySessionStateRequest {
|
|
|
63
63
|
* continues.
|
|
64
64
|
*/
|
|
65
65
|
context: Context;
|
|
66
|
+
/**
|
|
67
|
+
* `JSON.stringify(context.tools)` when the caller already serialized it
|
|
68
|
+
* (the gateway derives its `sessionId` from the same string). Recomputed
|
|
69
|
+
* from `context` when absent.
|
|
70
|
+
*/
|
|
71
|
+
toolsJson?: string;
|
|
66
72
|
/**
|
|
67
73
|
* Stable identity of the account this request's credential resolved to.
|
|
68
74
|
* A change means the gateway switched the session to a sibling credential,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast path for in-band scanners that sit inside a body waiting for one fixed
|
|
3
|
+
* terminator (a tool-call close tag) and emit nothing until it arrives.
|
|
4
|
+
*
|
|
5
|
+
* Appending each delta to the scanner's string buffer and calling `indexOf` on
|
|
6
|
+
* it flattens the growing rope on every delta, so a long in-band tool call cost
|
|
7
|
+
* O(n²) copying and scanning. While armed, deltas are set aside and only the new
|
|
8
|
+
* text plus the terminator-length overlap with what came before is searched;
|
|
9
|
+
* the buffer is rebuilt once, when the terminator shows up or the stream ends.
|
|
10
|
+
*/
|
|
11
|
+
export declare class TerminatorWait {
|
|
12
|
+
#private;
|
|
13
|
+
/** Start deferring; `buffered` is the scanner's pending body, known not to contain `terminator`. */
|
|
14
|
+
arm(terminator: string, buffered: string): void;
|
|
15
|
+
/** Take `text` while the terminator is still absent; false means the caller must {@link release} and scan. */
|
|
16
|
+
absorb(text: string): boolean;
|
|
17
|
+
/** Stop deferring and return `buffered` with every absorbed delta appended. */
|
|
18
|
+
release(buffered: string): string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
|
|
3
|
+
*
|
|
4
|
+
* Will not return API keys for providers that require OAuth tokens.
|
|
5
|
+
* Checks Bun.env, then cwd/.env, then ~/.env.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getEnvApiKey(provider: string): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Name of the environment variable that backs `getEnvApiKey` for a provider,
|
|
10
|
+
* when that provider maps to a single named variable (e.g. `github-copilot` →
|
|
11
|
+
* `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
|
|
12
|
+
* is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
|
|
13
|
+
* single variable name describes the source.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getEnvApiKeyName(provider: string): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
|
|
18
|
+
* Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
|
|
19
|
+
* that should be uploaded to the broker.
|
|
20
|
+
*/
|
|
21
|
+
export declare function listProvidersWithEnvKey(): string[];
|
|
@@ -32,6 +32,12 @@ export declare function decodeImageResponse(value: unknown, fetch: FetchImpl, si
|
|
|
32
32
|
images: GeneratedImage[];
|
|
33
33
|
usage: Usage;
|
|
34
34
|
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Mime type of a base64 image, read from its header only. 64 base64 chars
|
|
37
|
+
* decode to 48 bytes, past every magic number `parseImageMetadata` checks, so
|
|
38
|
+
* multi-MB payloads are never fully decoded just to be labelled.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sniffBase64ImageMimeType(base64: string): string | undefined;
|
|
35
41
|
export declare function toDataUrl(image: GeneratedImage): string;
|
|
36
42
|
export declare function resolveOpenAIImageSize(aspectRatio?: string, imageSize?: string): string | undefined;
|
|
37
43
|
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export type * from "./providers/openai-responses.js";
|
|
|
39
39
|
export type * from "./providers/synthetic.js";
|
|
40
40
|
export * from "./registry/index.js";
|
|
41
41
|
export { resolveCacheRetention } from "./utils.js";
|
|
42
|
+
export * from "./env-api-key.js";
|
|
42
43
|
export * from "./stream.js";
|
|
43
44
|
export * from "./types.js";
|
|
44
45
|
export * from "./usage.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect-protocol streaming envelope shared by the Cursor and Devin
|
|
3
|
+
* transports: 1 flag byte + 4-byte big-endian payload length + payload.
|
|
4
|
+
*/
|
|
5
|
+
/** Flag bit: payload is compressed with the negotiated encoding (gzip). */
|
|
6
|
+
export declare const CONNECT_COMPRESSED_FLAG = 1;
|
|
7
|
+
/** Flag bit: payload is the end-of-stream JSON trailer. */
|
|
8
|
+
export declare const CONNECT_END_STREAM_FLAG = 2;
|
|
9
|
+
/** Wrap one Connect-protocol message: 1 flag byte + 4-byte big-endian length + payload. */
|
|
10
|
+
export declare function frameConnectMessage(data: Uint8Array, flags?: number): Buffer;
|
|
11
|
+
/** One decoded Connect envelope. */
|
|
12
|
+
export interface ConnectFrame {
|
|
13
|
+
flags: number;
|
|
14
|
+
payload: Buffer;
|
|
15
|
+
}
|
|
16
|
+
/** Options for {@link ConnectFrameDecoder}. */
|
|
17
|
+
export interface ConnectFrameDecoderOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Payload cap: a length prefix above `maxPayloadBytes` throws `error(length)`
|
|
20
|
+
* before any of that payload is buffered. Uncapped when omitted.
|
|
21
|
+
*/
|
|
22
|
+
limit?: {
|
|
23
|
+
maxPayloadBytes: number;
|
|
24
|
+
error: (length: number) => Error;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Incremental Connect frame decoder. Incoming chunks are kept as-is and a frame
|
|
29
|
+
* spanning several chunks is joined once, when the length the header announced
|
|
30
|
+
* has fully arrived — re-concatenating the pending bytes on every chunk made a
|
|
31
|
+
* multi-MB frame cost O(size × chunks).
|
|
32
|
+
*
|
|
33
|
+
* Payloads alias the chunk they came from whenever a frame fits in one chunk,
|
|
34
|
+
* so callers must not reuse chunk buffers they hand in.
|
|
35
|
+
*/
|
|
36
|
+
export declare class ConnectFrameDecoder {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(options?: ConnectFrameDecoderOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Buffer `chunk` (if any) and yield every frame now complete, in wire order.
|
|
41
|
+
* An oversize length prefix throws after the frames that precede it.
|
|
42
|
+
*/
|
|
43
|
+
decode(chunk?: Uint8Array): Generator<ConnectFrame, void, undefined>;
|
|
44
|
+
}
|
|
@@ -7,8 +7,6 @@ type ProtoUnknownField = {
|
|
|
7
7
|
interface CursorInteractionWriter {
|
|
8
8
|
write(frame: Uint8Array): void;
|
|
9
9
|
}
|
|
10
|
-
/** Wrap one Connect-protocol message: 1 flag byte + 4-byte big-endian length + payload. */
|
|
11
|
-
export declare function frameConnectMessage(data: Uint8Array, flags?: number): Buffer;
|
|
12
10
|
/** Well-formed protobuf-es `$unknown` entries on `message`; anything else on the bag is ignored. */
|
|
13
11
|
export declare function protoUnknownFields(message: object): ProtoUnknownField[];
|
|
14
12
|
/**
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -31,27 +31,6 @@ export declare const __providerInFlightForTesting: {
|
|
|
31
31
|
captureStaleLockRelease(provider: string): Promise<(() => Promise<void>) | null>;
|
|
32
32
|
captureLockDirRelease(provider: string): Promise<(() => Promise<void>) | null>;
|
|
33
33
|
};
|
|
34
|
-
/**
|
|
35
|
-
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
|
|
36
|
-
*
|
|
37
|
-
* Will not return API keys for providers that require OAuth tokens.
|
|
38
|
-
* Checks Bun.env, then cwd/.env, then ~/.env.
|
|
39
|
-
*/
|
|
40
|
-
export declare function getEnvApiKey(provider: string): string | undefined;
|
|
41
|
-
/**
|
|
42
|
-
* Name of the environment variable that backs `getEnvApiKey` for a provider,
|
|
43
|
-
* when that provider maps to a single named variable (e.g. `github-copilot` →
|
|
44
|
-
* `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
|
|
45
|
-
* is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
|
|
46
|
-
* single variable name describes the source.
|
|
47
|
-
*/
|
|
48
|
-
export declare function getEnvApiKeyName(provider: string): string | undefined;
|
|
49
|
-
/**
|
|
50
|
-
* Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
|
|
51
|
-
* Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
|
|
52
|
-
* that should be uploaded to the broker.
|
|
53
|
-
*/
|
|
54
|
-
export declare function listProvidersWithEnvKey(): string[];
|
|
55
34
|
export declare function stream<TApi extends Api>(model: Model<TApi>, context: Context, options?: OptionsForApi<TApi>): AssistantMessageEventStream;
|
|
56
35
|
export declare function complete<TApi extends Api>(model: Model<TApi>, context: Context, options?: OptionsForApi<TApi>): Promise<AssistantMessage>;
|
|
57
36
|
export declare function streamSimple<TApi extends Api>(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
|
@@ -2,7 +2,8 @@ import type { Usage } from "@oh-my-pi/pi-catalog/types";
|
|
|
2
2
|
export type TranscriptionResponseFormat = "json" | "verbose_json";
|
|
3
3
|
export type TranscriptionTimestampGranularity = "word" | "segment";
|
|
4
4
|
export interface TranscriptionRequest {
|
|
5
|
-
|
|
5
|
+
/** Audio bytes. A `Blob`/`File` (e.g. a parsed multipart upload) is sent without copying. */
|
|
6
|
+
audio: Uint8Array | Blob;
|
|
6
7
|
mimeType: string;
|
|
7
8
|
fileName?: string;
|
|
8
9
|
language?: string;
|
|
@@ -9,6 +9,11 @@ export declare function resolveAwsProfile(profile?: string): string;
|
|
|
9
9
|
* the AWS SDK's `AWS_SDK_LOAD_CONFIG` opt-in.
|
|
10
10
|
*/
|
|
11
11
|
export declare function shouldLoadAwsSharedConfig(profile?: string): boolean;
|
|
12
|
+
/** Shared credentials and config file locations, honoring their env overrides. */
|
|
13
|
+
export declare function awsSharedFilePaths(): {
|
|
14
|
+
credentialsPath: string;
|
|
15
|
+
configPath: string;
|
|
16
|
+
};
|
|
12
17
|
export declare function resolveAwsProfileRegion(profile?: string): string | undefined;
|
|
13
18
|
/** Region selected by the environment or active shared-config profile. */
|
|
14
19
|
export declare function resolveAwsAmbientRegion(profile?: string): string | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.8.0",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -155,11 +155,11 @@
|
|
|
155
155
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
-
"@oh-my-pi/omptype": "18.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.
|
|
158
|
+
"@oh-my-pi/omptype": "18.8.0",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.8.0",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.8.0",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.8.0",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.8.0"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/auth/affinity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import {
|
|
2
|
+
import { LRUCache } from "@oh-my-pi/pi-utils/lru";
|
|
3
|
+
import { getEnvApiKey } from "../env-api-key";
|
|
3
4
|
import type { AuthCredential, OAuthCredential, SessionsApi } from "./types";
|
|
4
5
|
import type { AuthCredentialStore } from "./store";
|
|
5
6
|
import type { CredentialPool } from "./pool";
|
|
@@ -14,6 +15,11 @@ const SESSION_STICKY_TTL_SEC = 30 * 24 * 60 * 60;
|
|
|
14
15
|
* in-memory sticky stays exact; only the copy other processes resume from lags.
|
|
15
16
|
*/
|
|
16
17
|
const SESSION_STICKY_PERSIST_INTERVAL_MS = 60_000;
|
|
18
|
+
/**
|
|
19
|
+
* In-memory pins kept per provider. Long-lived gateways mint a session id per
|
|
20
|
+
* conversation; evicted sessions resume from their persisted sticky row.
|
|
21
|
+
*/
|
|
22
|
+
const SESSION_AFFINITY_MAX_SESSIONS_PER_PROVIDER = 256;
|
|
17
23
|
|
|
18
24
|
/** What this process last wrote (or read) for one persisted sticky row. */
|
|
19
25
|
type PersistedSticky = {
|
|
@@ -36,9 +42,9 @@ export type SessionCredential = {
|
|
|
36
42
|
/** Session → credential affinity (pins), persisted in the store cache. */
|
|
37
43
|
export class SessionAffinity implements SessionsApi {
|
|
38
44
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
39
|
-
#sessionLastCredential: Map<string,
|
|
40
|
-
/** Persisted sticky rows keyed by
|
|
41
|
-
#persistedSticky: Map<string, PersistedSticky
|
|
45
|
+
#sessionLastCredential: Map<string, LRUCache<string, SessionCredential>> = new Map();
|
|
46
|
+
/** Persisted sticky rows per provider, keyed by session id, so unchanged re-records skip the write. */
|
|
47
|
+
#persistedSticky: Map<string, LRUCache<string, PersistedSticky>> = new Map();
|
|
42
48
|
#store: AuthCredentialStore;
|
|
43
49
|
#pool: CredentialPool;
|
|
44
50
|
#overrides: KeyOverrides;
|
|
@@ -49,13 +55,21 @@ export class SessionAffinity implements SessionsApi {
|
|
|
49
55
|
this.#overrides = overrides;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/** Bounded per-provider session map, created on first use. */
|
|
59
|
+
static #sessionsFor<V>(maps: Map<string, LRUCache<string, V>>, provider: string): LRUCache<string, V> {
|
|
60
|
+
let sessions = maps.get(provider);
|
|
61
|
+
if (!sessions) {
|
|
62
|
+
sessions = new LRUCache<string, V>({ max: SESSION_AFFINITY_MAX_SESSIONS_PER_PROVIDER });
|
|
63
|
+
maps.set(provider, sessions);
|
|
64
|
+
}
|
|
65
|
+
return sessions;
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
/** Drop every pin for a provider, both in memory and in the persisted cache. */
|
|
53
69
|
clearProvider(provider: string): void {
|
|
54
70
|
this.#sessionLastCredential.delete(provider);
|
|
71
|
+
this.#persistedSticky.delete(provider);
|
|
55
72
|
const prefix = `${SESSION_STICKY_CACHE_PREFIX}${provider}:`;
|
|
56
|
-
for (const cacheKey of this.#persistedSticky.keys()) {
|
|
57
|
-
if (cacheKey.startsWith(prefix)) this.#persistedSticky.delete(cacheKey);
|
|
58
|
-
}
|
|
59
73
|
try {
|
|
60
74
|
this.#store.deleteCachePrefix?.(prefix);
|
|
61
75
|
} catch (err) {
|
|
@@ -90,7 +104,7 @@ export class SessionAffinity implements SessionsApi {
|
|
|
90
104
|
if (!sessionId) return;
|
|
91
105
|
const nowMs = lastUsedAtMs ?? Date.now();
|
|
92
106
|
const credentialId = this.#pool.entries(provider)[index]?.id;
|
|
93
|
-
const sessionMap = this.#sessionLastCredential
|
|
107
|
+
const sessionMap = SessionAffinity.#sessionsFor(this.#sessionLastCredential, provider);
|
|
94
108
|
const previous = sessionMap.get(sessionId);
|
|
95
109
|
const sameCredential =
|
|
96
110
|
previous?.type === type &&
|
|
@@ -104,12 +118,12 @@ export class SessionAffinity implements SessionsApi {
|
|
|
104
118
|
...(isExplicit ? { explicit: true as const } : {}),
|
|
105
119
|
};
|
|
106
120
|
sessionMap.set(sessionId, sessionCredential);
|
|
107
|
-
this.#sessionLastCredential.set(provider, sessionMap);
|
|
108
121
|
|
|
109
122
|
if (credentialId === undefined) return;
|
|
110
123
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
111
124
|
const expiresAtSec = Math.floor(nowMs / 1000) + SESSION_STICKY_TTL_SEC;
|
|
112
|
-
const
|
|
125
|
+
const persistedSessions = SessionAffinity.#sessionsFor(this.#persistedSticky, provider);
|
|
126
|
+
const persisted = persistedSessions.get(sessionId);
|
|
113
127
|
if (
|
|
114
128
|
persisted &&
|
|
115
129
|
persisted.type === type &&
|
|
@@ -121,14 +135,14 @@ export class SessionAffinity implements SessionsApi {
|
|
|
121
135
|
}
|
|
122
136
|
try {
|
|
123
137
|
this.#store.setCache(cacheKey, JSON.stringify(sessionCredential), expiresAtSec);
|
|
124
|
-
|
|
138
|
+
persistedSessions.set(sessionId, {
|
|
125
139
|
type,
|
|
126
140
|
credentialId,
|
|
127
141
|
explicit: isExplicit,
|
|
128
142
|
lastUsedAtMs: nowMs,
|
|
129
143
|
});
|
|
130
144
|
} catch (err) {
|
|
131
|
-
|
|
145
|
+
persistedSessions.delete(sessionId);
|
|
132
146
|
logger.debug("Failed to write session sticky credential to persistent store cache", { err });
|
|
133
147
|
}
|
|
134
148
|
}
|
|
@@ -136,8 +150,7 @@ export class SessionAffinity implements SessionsApi {
|
|
|
136
150
|
/** Retrieves the last credential used by a session. */
|
|
137
151
|
get(provider: string, sessionId: string | undefined): SessionCredential | undefined {
|
|
138
152
|
if (!sessionId) return undefined;
|
|
139
|
-
|
|
140
|
-
const live = sessionMap?.get(sessionId);
|
|
153
|
+
const live = this.#sessionLastCredential.get(provider)?.get(sessionId);
|
|
141
154
|
if (live) {
|
|
142
155
|
// Another process can add or drop rows mid-session and the pool is an
|
|
143
156
|
// index-ordered snapshot, so re-resolve the pin through its durable row
|
|
@@ -147,7 +160,7 @@ export class SessionAffinity implements SessionsApi {
|
|
|
147
160
|
const stored = this.#pool.entries(provider);
|
|
148
161
|
const actualIndex = stored.findIndex(entry => entry.id === live.credentialId);
|
|
149
162
|
if (actualIndex === -1 || stored[actualIndex]?.credential.type !== live.type) {
|
|
150
|
-
|
|
163
|
+
this.#sessionLastCredential.get(provider)?.delete(sessionId);
|
|
151
164
|
return undefined;
|
|
152
165
|
}
|
|
153
166
|
live.index = actualIndex;
|
|
@@ -163,22 +176,18 @@ export class SessionAffinity implements SessionsApi {
|
|
|
163
176
|
const stored = this.#pool.entries(provider);
|
|
164
177
|
const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
|
|
165
178
|
if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
|
|
166
|
-
this.#persistedSticky.delete(
|
|
179
|
+
this.#persistedSticky.get(provider)?.delete(sessionId);
|
|
167
180
|
this.#store.setCache(cacheKey, "", 0);
|
|
168
181
|
return undefined;
|
|
169
182
|
}
|
|
170
183
|
val.index = actualIndex;
|
|
171
184
|
} else {
|
|
172
185
|
// Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
|
|
173
|
-
this.#persistedSticky.delete(
|
|
186
|
+
this.#persistedSticky.get(provider)?.delete(sessionId);
|
|
174
187
|
this.#store.setCache(cacheKey, "", 0);
|
|
175
188
|
return undefined;
|
|
176
189
|
}
|
|
177
190
|
|
|
178
|
-
if (!sessionMap) {
|
|
179
|
-
sessionMap = new Map();
|
|
180
|
-
this.#sessionLastCredential.set(provider, sessionMap);
|
|
181
|
-
}
|
|
182
191
|
const sessionVal: SessionCredential = {
|
|
183
192
|
type: val.type,
|
|
184
193
|
index: val.index,
|
|
@@ -187,14 +196,14 @@ export class SessionAffinity implements SessionsApi {
|
|
|
187
196
|
...(val.explicit === true ? { explicit: true } : {}),
|
|
188
197
|
};
|
|
189
198
|
if (typeof val.lastUsedAtMs === "number") {
|
|
190
|
-
this.#persistedSticky.set(
|
|
199
|
+
SessionAffinity.#sessionsFor(this.#persistedSticky, provider).set(sessionId, {
|
|
191
200
|
type: val.type,
|
|
192
201
|
credentialId: val.credentialId,
|
|
193
202
|
explicit: val.explicit === true,
|
|
194
203
|
lastUsedAtMs: val.lastUsedAtMs,
|
|
195
204
|
});
|
|
196
205
|
}
|
|
197
|
-
|
|
206
|
+
SessionAffinity.#sessionsFor(this.#sessionLastCredential, provider).set(sessionId, sessionVal);
|
|
198
207
|
return sessionVal;
|
|
199
208
|
}
|
|
200
209
|
} catch (err) {
|
|
@@ -214,7 +223,7 @@ export class SessionAffinity implements SessionsApi {
|
|
|
214
223
|
}
|
|
215
224
|
}
|
|
216
225
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
217
|
-
this.#persistedSticky.delete(
|
|
226
|
+
this.#persistedSticky.get(provider)?.delete(sessionId);
|
|
218
227
|
try {
|
|
219
228
|
this.#store.setCache(cacheKey, "", 0);
|
|
220
229
|
} catch (err) {
|
package/src/auth/blocks.ts
CHANGED
|
@@ -64,6 +64,14 @@ export function credentialBlockScopesForRequest(
|
|
|
64
64
|
*/
|
|
65
65
|
type CredentialBackoff = { until: number; timed: boolean; probeAfter: number; persisted?: boolean };
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* One read of a credential's persisted blocks under a providerKey, keyed by
|
|
69
|
+
* scope (`""` is unscoped). `undefined`: the store has no bulk read, so use
|
|
70
|
+
* per-scope point reads. `null`: unreadable (damaged store or failed read), so
|
|
71
|
+
* it neither contributes deadlines nor retires mirrored in-memory blocks.
|
|
72
|
+
*/
|
|
73
|
+
type PersistedBlockScopes = ReadonlyMap<string, number> | null | undefined;
|
|
74
|
+
|
|
67
75
|
/** Latch for unrecoverably corrupt persisted block stores, shared with the credential pool. */
|
|
68
76
|
export class BlockStoreHealth {
|
|
69
77
|
readonly #sourceLabel: string | undefined;
|
|
@@ -134,8 +142,16 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
144
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Returns in-memory block expiry timestamp for a credential/key pair, cleaning up expired entries.
|
|
147
|
+
* `persistedScopes` is a bulk read for the key's providerKey; omitted, the store is point-read.
|
|
148
|
+
*/
|
|
149
|
+
#getCredentialBlockedUntilForKey(
|
|
150
|
+
backoffKey: string,
|
|
151
|
+
credentialId: number,
|
|
152
|
+
nowMs: number,
|
|
153
|
+
persistedScopes: PersistedBlockScopes = undefined,
|
|
154
|
+
): number | undefined {
|
|
139
155
|
const block = this.#credentialBackoff.get(backoffKey)?.get(credentialId);
|
|
140
156
|
if (!block) return undefined;
|
|
141
157
|
// Once mirrored successfully, the store is authoritative for cross-process
|
|
@@ -143,12 +159,19 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
143
159
|
// Upserts are longest-wins, so a persisted row shorter than the deadline this
|
|
144
160
|
// process mirrored means the row was deleted and replaced: the old deadline
|
|
145
161
|
// was superseded and must not win the next longest-wins merge.
|
|
146
|
-
if (
|
|
162
|
+
if (
|
|
163
|
+
block.persisted &&
|
|
164
|
+
!this.#deps.health.damaged &&
|
|
165
|
+
persistedScopes !== null &&
|
|
166
|
+
(persistedScopes !== undefined || this.#deps.store.getCredentialBlock)
|
|
167
|
+
) {
|
|
147
168
|
const separator = backoffKey.indexOf("\0");
|
|
148
169
|
const providerKey = separator < 0 ? backoffKey : backoffKey.slice(0, separator);
|
|
149
170
|
const scope = separator < 0 ? "" : backoffKey.slice(separator + 1);
|
|
150
171
|
try {
|
|
151
|
-
const persisted =
|
|
172
|
+
const persisted = persistedScopes
|
|
173
|
+
? persistedScopes.get(scope)
|
|
174
|
+
: this.#deps.store.getCredentialBlock?.(credentialId, providerKey, scope);
|
|
152
175
|
if (persisted === undefined || persisted < block.until) {
|
|
153
176
|
this.#deleteCredentialBackoff(backoffKey, credentialId);
|
|
154
177
|
return undefined;
|
|
@@ -175,7 +198,9 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
175
198
|
credentialId: number,
|
|
176
199
|
providerKey: string,
|
|
177
200
|
blockScope: string | undefined,
|
|
201
|
+
persistedScopes: PersistedBlockScopes = undefined,
|
|
178
202
|
): number | undefined {
|
|
203
|
+
if (persistedScopes !== undefined) return persistedScopes?.get(blockScope ?? "");
|
|
179
204
|
if (this.#deps.health.damaged) return undefined;
|
|
180
205
|
const getCredentialBlock = this.#deps.store.getCredentialBlock?.bind(this.#deps.store);
|
|
181
206
|
if (!getCredentialBlock) return undefined;
|
|
@@ -193,6 +218,20 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
193
218
|
}
|
|
194
219
|
}
|
|
195
220
|
|
|
221
|
+
/** Every persisted scope of one credential/providerKey in a single store read, when supported. */
|
|
222
|
+
#readPersistedCredentialBlockScopes(credentialId: number, providerKey: string): PersistedBlockScopes {
|
|
223
|
+
const store = this.#deps.store;
|
|
224
|
+
if (!store.getCredentialBlockScopes) return undefined;
|
|
225
|
+
if (this.#deps.health.damaged) return null;
|
|
226
|
+
try {
|
|
227
|
+
return store.getCredentialBlockScopes(credentialId, providerKey);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (this.#deps.health.handle(err)) return null;
|
|
230
|
+
logger.debug("Failed to read credential blocks from persistent store", { err, credentialId, providerKey });
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
196
235
|
#readPersistedCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number {
|
|
197
236
|
if (this.#deps.health.damaged) return 0;
|
|
198
237
|
const getCredentialBlockReconcileAfter = this.#deps.store.getCredentialBlockReconcileAfter?.bind(
|
|
@@ -215,13 +254,19 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
215
254
|
}
|
|
216
255
|
}
|
|
217
256
|
|
|
218
|
-
#exactBlockedUntil(
|
|
257
|
+
#exactBlockedUntil(
|
|
258
|
+
credentialId: number,
|
|
259
|
+
providerKey: string,
|
|
260
|
+
scope: string | undefined,
|
|
261
|
+
persistedScopes: PersistedBlockScopes = this.#readPersistedCredentialBlockScopes(credentialId, providerKey),
|
|
262
|
+
): number | undefined {
|
|
219
263
|
const memory = this.#getCredentialBlockedUntilForKey(
|
|
220
264
|
scopedBackoffKey(providerKey, scope),
|
|
221
265
|
credentialId,
|
|
222
266
|
Date.now(),
|
|
267
|
+
persistedScopes,
|
|
223
268
|
);
|
|
224
|
-
const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope);
|
|
269
|
+
const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope, persistedScopes);
|
|
225
270
|
return memory === undefined ? persisted : persisted === undefined ? memory : Math.max(memory, persisted);
|
|
226
271
|
}
|
|
227
272
|
|
|
@@ -231,6 +276,22 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
231
276
|
providerKey: string,
|
|
232
277
|
credentialIndex: number,
|
|
233
278
|
blockScopeOrScopes: string | readonly string[] | undefined = undefined,
|
|
279
|
+
): number | undefined {
|
|
280
|
+
const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
|
|
281
|
+
if (credentialId === undefined) return undefined;
|
|
282
|
+
return this.#blockedUntilById(
|
|
283
|
+
credentialId,
|
|
284
|
+
providerKey,
|
|
285
|
+
blockScopeOrScopes,
|
|
286
|
+
this.#readPersistedCredentialBlockScopes(credentialId, providerKey),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#blockedUntilById(
|
|
291
|
+
credentialId: number,
|
|
292
|
+
providerKey: string,
|
|
293
|
+
blockScopeOrScopes: string | readonly string[] | undefined,
|
|
294
|
+
persistedScopes: PersistedBlockScopes,
|
|
234
295
|
): number | undefined {
|
|
235
296
|
const nowMs = Date.now();
|
|
236
297
|
// A request honours its own scope plus any legacy catch-all scope, so a
|
|
@@ -239,21 +300,25 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
239
300
|
...PROTECTED_BLOCK_SCOPES,
|
|
240
301
|
...(typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])),
|
|
241
302
|
].filter(scope => scope.length > 0);
|
|
242
|
-
|
|
243
|
-
if (credentialId === undefined) return undefined;
|
|
244
|
-
let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs);
|
|
303
|
+
let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs, persistedScopes);
|
|
245
304
|
for (const blockScope of scopes) {
|
|
246
305
|
const scopedBlockedUntil = this.#getCredentialBlockedUntilForKey(
|
|
247
306
|
scopedBackoffKey(providerKey, blockScope),
|
|
248
307
|
credentialId,
|
|
249
308
|
nowMs,
|
|
309
|
+
persistedScopes,
|
|
250
310
|
);
|
|
251
311
|
if (scopedBlockedUntil !== undefined && (blockedUntil === undefined || scopedBlockedUntil > blockedUntil)) {
|
|
252
312
|
blockedUntil = scopedBlockedUntil;
|
|
253
313
|
}
|
|
254
314
|
}
|
|
255
315
|
|
|
256
|
-
const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(
|
|
316
|
+
const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(
|
|
317
|
+
credentialId,
|
|
318
|
+
providerKey,
|
|
319
|
+
"",
|
|
320
|
+
persistedScopes,
|
|
321
|
+
);
|
|
257
322
|
if (
|
|
258
323
|
persistedGlobalBlockedUntil !== undefined &&
|
|
259
324
|
(blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
|
|
@@ -261,7 +326,12 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
261
326
|
blockedUntil = persistedGlobalBlockedUntil;
|
|
262
327
|
}
|
|
263
328
|
for (const blockScope of scopes) {
|
|
264
|
-
const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(
|
|
329
|
+
const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(
|
|
330
|
+
credentialId,
|
|
331
|
+
providerKey,
|
|
332
|
+
blockScope,
|
|
333
|
+
persistedScopes,
|
|
334
|
+
);
|
|
265
335
|
if (
|
|
266
336
|
persistedScopedBlockedUntil !== undefined &&
|
|
267
337
|
(blockedUntil === undefined || persistedScopedBlockedUntil > blockedUntil)
|
|
@@ -440,14 +510,20 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
440
510
|
if (!this.supportsHealing(provider)) return false;
|
|
441
511
|
const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
|
|
442
512
|
if (credentialId === undefined) return false;
|
|
443
|
-
|
|
513
|
+
// One persisted read serves the protected-scope, global, and requested checks.
|
|
514
|
+
const persistedScopes = this.#readPersistedCredentialBlockScopes(credentialId, providerKey);
|
|
515
|
+
if (
|
|
516
|
+
PROTECTED_BLOCK_SCOPES.some(
|
|
517
|
+
scope => this.#exactBlockedUntil(credentialId, providerKey, scope, persistedScopes) !== undefined,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
444
520
|
return false;
|
|
445
521
|
if (
|
|
446
522
|
!this.#deps.strategies(provider)?.healsGlobalBlocks &&
|
|
447
|
-
this
|
|
523
|
+
this.#blockedUntilById(credentialId, providerKey, undefined, persistedScopes) !== undefined
|
|
448
524
|
)
|
|
449
525
|
return false;
|
|
450
|
-
return this
|
|
526
|
+
return this.#blockedUntilById(credentialId, providerKey, blockScopeOrScopes, persistedScopes) !== undefined;
|
|
451
527
|
}
|
|
452
528
|
|
|
453
529
|
/**
|