@oh-my-pi/pi-ai 18.8.2 → 18.8.4
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 +33 -64
- package/dist/types/auth/cascade.d.ts +12 -3
- package/dist/types/auth/oauth.d.ts +5 -3
- package/dist/types/auth/select.d.ts +2 -0
- package/dist/types/auth/store.d.ts +2 -2
- package/dist/types/auth/types.d.ts +17 -4
- package/dist/types/auth-broker/client.d.ts +1 -1
- package/dist/types/auth-broker/remote-store.d.ts +1 -1
- package/dist/types/auth-broker/server.d.ts +2 -0
- package/dist/types/auth-gateway/dispatch.d.ts +2 -0
- package/dist/types/auth-gateway/http.d.ts +0 -2
- package/dist/types/error/auth.d.ts +10 -0
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/registry/oauth/cursor.d.ts +1 -1
- package/dist/types/utils/resolve-peer.d.ts +2 -0
- package/package.json +6 -6
- package/src/auth/cascade.ts +36 -6
- package/src/auth/oauth.ts +5 -3
- package/src/auth/resets.ts +1 -1
- package/src/auth/select.ts +30 -10
- package/src/auth/store.ts +2 -2
- package/src/auth/types.ts +17 -4
- package/src/auth/usage.ts +1 -1
- package/src/auth-broker/client.ts +5 -2
- package/src/auth-broker/remote-store.ts +2 -2
- package/src/auth-broker/server.ts +33 -7
- package/src/auth-gateway/dispatch.ts +2 -0
- package/src/auth-gateway/http.ts +0 -8
- package/src/auth-gateway/server.ts +10 -9
- package/src/error/auth.ts +20 -0
- package/src/provider-session-state.ts +2 -2
- package/src/providers/anthropic.ts +52 -8
- package/src/providers/cursor.ts +27 -1
- package/src/registry/oauth/cursor.ts +25 -16
- package/src/usage/github-copilot.ts +1 -1
- package/src/utils/resolve-peer.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.4] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- `AuthBrokerClient.notifyUsageStale` and `UsageLedgerStore.invalidateUsageCache` (including `RemoteAuthCredentialStore.invalidateUsageCache`) now take an optional leading `provider` argument: the signature is `(provider?: string, signal?: AbortSignal)` instead of `(signal?: AbortSignal)` ([#14761](https://github.com/can1357/oh-my-pi/pull/14761) by [@will-bogusz](https://github.com/will-bogusz))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Auth gateway route option `excludeProviders` leaves those providers' accounts out of `/v1/usage` and `/v1/credentials/check` ([#14755](https://github.com/can1357/oh-my-pi/pull/14755) by [@will-bogusz](https://github.com/will-bogusz))
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed a single-provider usage refresh from an auth broker client (`omp usage invalidate --provider`, Codex auto-redeem, a saved-reset redeem) wiping the broker's cached usage for every provider, so other accounts showed no usage on every client until their next successful probe ([#14761](https://github.com/can1357/oh-my-pi/pull/14761) by [@will-bogusz](https://github.com/will-bogusz))
|
|
18
|
+
- Fixed Anthropic sessions missing the prompt cache a second time after the API dropped thinking blocks from a changed conversation ([#14748](https://github.com/can1357/oh-my-pi/pull/14748) by [@will-bogusz](https://github.com/will-bogusz))
|
|
19
|
+
- Fixed Cursor logins never renewing: every refresh was rejected with "Invalid User API Key", so logins lapsed about 60 days after sign-in; a session Cursor has ended is now disabled with a re-login hint ([#14753](https://github.com/can1357/oh-my-pi/pull/14753) by [@will-bogusz](https://github.com/will-bogusz))
|
|
20
|
+
- Fixed the auth broker logging caller-supplied `X-Forwarded-For` / `X-Real-IP` values and unknown request paths; peers come from the socket unless `trustProxyHeaders` is set ([#14762](https://github.com/can1357/oh-my-pi/pull/14762) by [@will-bogusz](https://github.com/will-bogusz))
|
|
21
|
+
- Fixed false thinking-loop detections on Gemini, DeepSeek, and Grok when the model drafts or answers with repetitive code or markup (VRML, SVG, JSON); such output is no longer discarded and retried.
|
|
22
|
+
- Fixed Cursor provider leaking conversation checkpoints, blob stores and abort listeners after requests end ([#14669](https://github.com/can1357/oh-my-pi/pull/14669) by [@H4vC](https://github.com/H4vC))
|
|
23
|
+
- Fixed session-affinity pins growing without bound in long-lived gateways (now capped at 256 sessions per provider) ([#14672](https://github.com/can1357/oh-my-pi/pull/14672) by [@H4vC](https://github.com/H4vC))
|
|
24
|
+
- Fixed completed Cursor turns failing with "Cursor stream ended before turnEnded" when the connection closed after the answer had fully arrived ([#14851](https://github.com/can1357/oh-my-pi/pull/14851) by [@kyle-elliott-asymptote](https://github.com/kyle-elliott-asymptote)).
|
|
25
|
+
- Fixed Cursor provider errors that Cursor marks as not retryable being retried until the retry budget ran out ([#14851](https://github.com/can1357/oh-my-pi/pull/14851) by [@kyle-elliott-asymptote](https://github.com/kyle-elliott-asymptote)).
|
|
26
|
+
|
|
27
|
+
## [18.8.3] - 2026-10-07
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Added `OAuthRefreshUnavailableError`, a retryable error that `keys.getWithCredential` and `oauth.access` reject with when every usable OAuth credential failed to refresh transiently; `keys.get` resolves `undefined` instead so availability probes move on to their next candidate ([#14843](https://github.com/can1357/oh-my-pi/pull/14843) by [@H4vC](https://github.com/H4vC))
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Fixed a single transient OAuth token-refresh failure (network blip, timeout, 5xx) ending a running session, including subagents restricted to an account pool, with a non-retryable "No API key for provider" error while the stored credential was still valid; the refresh error now surfaces and the request is retried ([#14843](https://github.com/can1357/oh-my-pi/pull/14843) by [@H4vC](https://github.com/H4vC))
|
|
36
|
+
|
|
5
37
|
## [18.8.1] - 2026-10-07
|
|
6
38
|
|
|
7
39
|
### Added
|
|
@@ -2297,67 +2329,4 @@
|
|
|
2297
2329
|
- Fixed `validateToolArguments` silently accepting JSON-encoded array strings (e.g. `'["a","b"]'`) against `union(string, array<string>)` schemas — providers that double-serialize tool-call arguments (Z.AI / GLM) caused tools like `search` to receive the literal `["a","b"]` as a single path, producing zero matches (single element) or glob parse errors (multi-element). A new pre-validation pass parses JSON-array-shaped strings when the schema explicitly accepts both shapes. ([#1788](https://github.com/can1357/oh-my-pi/issues/1788))
|
|
2298
2330
|
- Fixed Anthropic thinking summaries that arrive wrapped in literal `<thinking>` tags so advisor/raw transcript dumps do not render nested thinking tags ([#2695](https://github.com/can1357/oh-my-pi/issues/2695)).
|
|
2299
2331
|
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
### Breaking Changes
|
|
2303
|
-
|
|
2304
|
-
- Renamed the public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect`.
|
|
2305
|
-
- Renamed grammar dialect identifiers from `ToolCallSyntax` to `Dialect`, renamed the `Grammar` interface to `DialectDefinition`, and renamed `Grammar.syntax` to `DialectDefinition.dialect`.
|
|
2306
|
-
- Added `DialectDefinition.renderThinking` and `DialectDefinition.renderTranscript` so dialect implementations serialize complete native chat transcripts, not just tool call/result blocks.
|
|
2307
|
-
|
|
2308
|
-
### Added
|
|
2309
|
-
|
|
2310
|
-
- Added `renderTranscript` method to dialect definitions for serializing complete native chat transcripts
|
|
2311
|
-
- Added `renderThinking` method to dialect definitions for rendering thinking/reasoning blocks
|
|
2312
|
-
- Added support for 11 dialect implementations: Anthropic, DeepSeek, Gemini, Gemma, GLM, Harmony, Hermes, Kimi, Pi-native, Qwen3, and XML
|
|
2313
|
-
- Added `createInbandScanner` factory function to instantiate dialect-specific scanners
|
|
2314
|
-
- Added `getDialectDefinition` function to retrieve dialect implementations by name
|
|
2315
|
-
- Added `renderToolCatalog` and `renderInbandToolPrompt` functions for tool catalog rendering
|
|
2316
|
-
- Added `renderToolInventory` function to generate human-readable per-tool documentation with examples
|
|
2317
|
-
- Added `renderToolExamples` function to render tool usage examples in the model's native dialect
|
|
2318
|
-
- Added `encodeInbandToolHistory` function to encode tool call history in dialect-specific format
|
|
2319
|
-
- Added `wrapInbandToolStream` function to process streaming responses with in-band tool call parsing
|
|
2320
|
-
- Added `ThinkingInbandScanner` for parsing thinking/reasoning blocks across dialects
|
|
2321
|
-
- Added `OwnedStream` class for managing dialect-aware streaming with tool call events
|
|
2322
|
-
- Added in-band thinking channels to every dialect that was missing one: `gemini` (a ` ```thinking ` fence mirroring ` ```tool_code `), `gemma` (its native `<|channel>thought…<channel|>` reasoning channel), `kimi` (`<think>…</think>`), and `pi` (`<thinking>…</thinking>`). Each scanner now parses reasoning into thinking events instead of leaking chain-of-thought into the visible reply, and every dialect's `renderThinking` is a real channel that round-trips back through its scanner (no passthrough renderers).
|
|
2323
|
-
|
|
2324
|
-
### Changed
|
|
2325
|
-
|
|
2326
|
-
- Moved public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect` in package exports
|
|
2327
|
-
- Updated internal imports in `stream-markup-healing.ts` to use new dialect module path
|
|
2328
|
-
- Changed `renderToolInventory` to demote a tool description's own markdown headers by one level when it contains a top-level `# ` header, so they nest under the wrapping `# Tool: <name>` heading instead of reading as sibling sections. Descriptions that already start at `##` and headers inside fenced code blocks are left untouched.
|
|
2329
|
-
|
|
2330
|
-
### Fixed
|
|
2331
|
-
|
|
2332
|
-
- Fixed Gemini, Gemma, Kimi, and Pi in-band scanners to respect `parseThinking: false`, leaving private reasoning markers in visible text when parsing is disabled
|
|
2333
|
-
- Fixed thinking-channel parsing for streaming Gemini, Gemma, Kimi, and Pi outputs so split or partial `<thinking>` blocks no longer leak into visible replies
|
|
2334
|
-
- Fixed in-band thinking finalization and Kimi stream-healing interactions so leaked `<think>` blocks are preserved when structured tool calls are present, not duplicated when explicit reasoning is present, and closed on stream flush.
|
|
2335
|
-
|
|
2336
|
-
### Removed
|
|
2337
|
-
|
|
2338
|
-
- Removed `src/grammar/factory.ts` (replaced by `src/dialect/factory.ts`)
|
|
2339
|
-
- Removed `src/grammar/rendering.ts` (functionality moved to `src/dialect/rendering.ts`)
|
|
2340
|
-
- Removed `src/grammar/xml.ts` (replaced by `src/dialect/xml.ts`)
|
|
2341
|
-
|
|
2342
|
-
## [15.13.3] - 2026-06-15
|
|
2343
|
-
|
|
2344
|
-
### Added
|
|
2345
|
-
|
|
2346
|
-
- Added the `gemini` in-band tool-call syntax with Python-style `tool_code` blocks and `default_api` invocations
|
|
2347
|
-
- Added the `gemma` token-delimited in-band tool-call syntax using `<|tool_call>` and `<|tool_response>` blocks
|
|
2348
|
-
- Added `gemini` and `gemma` to owned stream tool-result token detection so their tool responses are recognized
|
|
2349
|
-
- Fixed truncated Gemini and Gemma tool blocks from being emitted as plain text during streaming
|
|
2350
|
-
- Added the Azure OpenAI provider definition (`azure`) to the registry; `AZURE_OPENAI_API_KEY` resolves as its env-var API key via the catalog provider table.
|
|
2351
|
-
|
|
2352
|
-
### Changed
|
|
2353
|
-
|
|
2354
|
-
- Gemini tool-call examples now render without the `default_api.` namespace prefix, keeping `<example>` blocks concise. The live wire format still uses `default_api.` per the Gemini grammar.
|
|
2355
|
-
|
|
2356
|
-
### Fixed
|
|
2357
|
-
|
|
2358
|
-
- Fixed duplicate tool call projections by deduplicating provider-native `toolCall` events against in-band `tool_code` calls and keeping only the first real channel
|
|
2359
|
-
- Dropped nameless native `toolCall` events so they no longer appear as surfaced tool calls in owned-mode streams
|
|
2360
|
-
- Fixed truncated Gemini and Gemma tool blocks from being emitted as plain text during streaming
|
|
2361
|
-
- Fixed Gemini/Gemma in-band tool-call parsing around Python comments, raw/unicode string literals, and Gemma close-token text inside string values.
|
|
2362
|
-
|
|
2363
|
-
Older entries are archived in [packages/ai/CHANGELOG.md@a20cc0d04b64](https://github.com/can1357/oh-my-pi/blob/a20cc0d04b64f4a68fcc559cbd258743d984c50e/packages/ai/CHANGELOG.md).
|
|
2332
|
+
Older entries are archived in [packages/ai/CHANGELOG.md@d22e34333695](https://github.com/can1357/oh-my-pi/blob/d22e34333695d3dd9de7741ff45903681dba4606/packages/ai/CHANGELOG.md).
|
|
@@ -2,7 +2,7 @@ import { type ApiKeyResolver, type ResolvedApiKey } from "../auth-retry.js";
|
|
|
2
2
|
import type { SessionAffinity } from "./affinity.js";
|
|
3
3
|
import type { CredentialPool } from "./pool.js";
|
|
4
4
|
import type { CredentialSelector } from "./select.js";
|
|
5
|
-
import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi
|
|
5
|
+
import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi } from "./types.js";
|
|
6
6
|
/** Runtime (--api-key) and config (models.yml) key overrides plus the config-value resolver. */
|
|
7
7
|
export declare class KeyOverrides {
|
|
8
8
|
#private;
|
|
@@ -117,7 +117,13 @@ export declare class KeyCascade implements KeysApi {
|
|
|
117
117
|
* credential to reach the correct host.
|
|
118
118
|
*/
|
|
119
119
|
peek(provider: string): Promise<string | undefined>;
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a bearer together with the stored row that supplied it, for a request.
|
|
122
|
+
* Same precedence as {@link KeyCascade.get}, but rejects with
|
|
123
|
+
* {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
|
|
124
|
+
* refresh failed with a retryable error and no other permitted source could serve
|
|
125
|
+
* the request, so request paths retry instead of reporting a missing key.
|
|
126
|
+
*/
|
|
121
127
|
getWithCredential(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined>;
|
|
122
128
|
/**
|
|
123
129
|
* Get API key for a provider.
|
|
@@ -127,8 +133,11 @@ export declare class KeyCascade implements KeysApi {
|
|
|
127
133
|
* OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
|
|
128
134
|
* can serve or a config key (a models.yml `apiKey`, often for a proxy
|
|
129
135
|
* `baseUrl`) owns the provider, rather than using any other credential.
|
|
136
|
+
* A transient OAuth refresh failure resolves `undefined`, so availability
|
|
137
|
+
* probes move on to their next candidate; request paths use
|
|
138
|
+
* {@link KeyCascade.getWithCredential}, which surfaces it.
|
|
130
139
|
*/
|
|
131
|
-
get(provider: string, sessionId?: string, options?: AuthApiKeyOptions
|
|
140
|
+
get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
|
|
132
141
|
/**
|
|
133
142
|
* Build an {@link ApiKeyResolver} backed by this storage, implementing the
|
|
134
143
|
* central a/b/c auth-retry policy:
|
|
@@ -37,9 +37,11 @@ export declare class OAuthAccounts implements OAuthApi {
|
|
|
37
37
|
* `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
|
|
38
38
|
* scenarios, prefer API-key resolution.
|
|
39
39
|
*
|
|
40
|
-
* Returns `undefined` when no OAuth credential is available
|
|
41
|
-
*
|
|
42
|
-
* OAuth with an explicit API key.
|
|
40
|
+
* Returns `undefined` when no usable OAuth credential is available
|
|
41
|
+
* (none stored, or every one definitively failed to refresh) or
|
|
42
|
+
* runtime/config overrides have replaced OAuth with an explicit API key.
|
|
43
|
+
* Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
|
|
44
|
+
* retryable) when a retryable refresh failure left no usable credential.
|
|
43
45
|
*/
|
|
44
46
|
access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
|
|
45
47
|
/**
|
|
@@ -32,6 +32,8 @@ export type TryOAuthOptions = {
|
|
|
32
32
|
blockScopes?: readonly string[];
|
|
33
33
|
/** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
|
|
34
34
|
allowFallback?: boolean;
|
|
35
|
+
/** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
|
|
36
|
+
onTransientRefreshFailure?: (error: unknown) => void;
|
|
35
37
|
};
|
|
36
38
|
/** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
|
|
37
39
|
export interface CredentialSelectorDeps {
|
|
@@ -122,8 +122,8 @@ export interface UsageLedgerStore {
|
|
|
122
122
|
}
|
|
123
123
|
/** Broker-delegated OAuth refresh and usage-report operations. */
|
|
124
124
|
export interface CredentialUpstream {
|
|
125
|
-
/** Optional hook to notify the underlying store that usage
|
|
126
|
-
invalidateUsageCache(signal?: AbortSignal): Promise<void>;
|
|
125
|
+
/** Optional hook to notify the underlying store that usage reports (one provider's, when given) are stale. */
|
|
126
|
+
invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
|
|
127
127
|
/**
|
|
128
128
|
* Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
|
|
129
129
|
* it before the per-provider local refresh path. `RemoteAuthCredentialStore`
|
|
@@ -812,9 +812,20 @@ export interface KeysApi {
|
|
|
812
812
|
* 4. API key persisted by a successful `/login`
|
|
813
813
|
* 5. Environment variable
|
|
814
814
|
* 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
|
|
815
|
+
*
|
|
816
|
+
* Resolves `undefined` when no permitted credential can serve, including when
|
|
817
|
+
* OAuth refresh failed transiently (network, timeout, 5xx), so availability
|
|
818
|
+
* probes move on to their next candidate. Request paths use
|
|
819
|
+
* {@link KeysApi.getWithCredential}, which surfaces that failure.
|
|
815
820
|
*/
|
|
816
821
|
get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
|
|
817
|
-
/**
|
|
822
|
+
/**
|
|
823
|
+
* Resolve a bearer together with its durable stored credential row id, when known.
|
|
824
|
+
* Same precedence as {@link KeysApi.get}, but rejects with
|
|
825
|
+
* `OAuthRefreshUnavailableError` (transient, retryable) when OAuth refresh failed
|
|
826
|
+
* with a retryable error and no other permitted source could serve the request;
|
|
827
|
+
* the stored credential is kept.
|
|
828
|
+
*/
|
|
818
829
|
getWithCredential(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined>;
|
|
819
830
|
/**
|
|
820
831
|
* Peek at API key for a provider without refreshing OAuth tokens.
|
|
@@ -935,9 +946,11 @@ export interface OAuthApi {
|
|
|
935
946
|
* `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
|
|
936
947
|
* scenarios, prefer {@link AuthStorage.keys.get}.
|
|
937
948
|
*
|
|
938
|
-
* Returns `undefined` when no OAuth credential is available
|
|
939
|
-
*
|
|
940
|
-
* OAuth with an explicit API key.
|
|
949
|
+
* Returns `undefined` when no usable OAuth credential is available (none
|
|
950
|
+
* stored, or every one definitively failed to refresh) or runtime/config
|
|
951
|
+
* overrides have replaced OAuth with an explicit API key. Rejects with
|
|
952
|
+
* `OAuthRefreshUnavailableError` (transient, retryable) when a retryable
|
|
953
|
+
* refresh failure left no usable credential.
|
|
941
954
|
*/
|
|
942
955
|
access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
|
|
943
956
|
/**
|
|
@@ -85,7 +85,7 @@ export declare class AuthBrokerClient {
|
|
|
85
85
|
fetchClientUsageSummary(query?: {
|
|
86
86
|
sinceMs?: number;
|
|
87
87
|
}, signal?: AbortSignal): Promise<ClientUsageSummaryResponse>;
|
|
88
|
-
notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse>;
|
|
88
|
+
notifyUsageStale(provider?: string, signal?: AbortSignal): Promise<UsageStaleResponse>;
|
|
89
89
|
refreshCredential(id: number, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<CredentialRefreshResponse>;
|
|
90
90
|
disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
|
|
91
91
|
/**
|
|
@@ -122,7 +122,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
122
122
|
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
123
123
|
deleteCachePrefix(prefix: string): void;
|
|
124
124
|
cleanExpiredCache(): void;
|
|
125
|
-
invalidateUsageCache(signal?: AbortSignal): Promise<void>;
|
|
125
|
+
invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
|
|
126
126
|
/**
|
|
127
127
|
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
128
128
|
* broker so the actual refresh token never leaves the broker host. Returns
|
|
@@ -17,6 +17,8 @@ export interface AuthBrokerServerOptions {
|
|
|
17
17
|
bind?: string;
|
|
18
18
|
/** Accept any of these bearer tokens. Empty disables auth (loopback only). */
|
|
19
19
|
bearerTokens: string[];
|
|
20
|
+
/** Honor forwarded peer headers only when the connecting proxy is trusted. Default false. */
|
|
21
|
+
trustProxyHeaders?: boolean;
|
|
20
22
|
/** Broker version string surfaced on `/v1/healthz`. */
|
|
21
23
|
version?: string;
|
|
22
24
|
/** Refresh credentials expiring within this window. Default 5 min. */
|
|
@@ -17,6 +17,8 @@ export interface AuthGatewayRouteOptions {
|
|
|
17
17
|
resolveModel: ModelResolver;
|
|
18
18
|
/** Optional supplier for `/v1/models` listing. Returns the full model array. */
|
|
19
19
|
listModels?: () => Iterable<Model<Api>>;
|
|
20
|
+
/** Providers the host does not serve; `/v1/usage` and `/v1/credentials/check` leave their accounts out. */
|
|
21
|
+
excludeProviders?: ReadonlySet<string>;
|
|
20
22
|
/** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
|
|
21
23
|
fetch?: FetchImpl;
|
|
22
24
|
}
|
|
@@ -16,8 +16,6 @@ export declare function gatewayResponseHeaders(model: Model<Api>, info: {
|
|
|
16
16
|
costUsd?: number;
|
|
17
17
|
startedAt?: number;
|
|
18
18
|
}): Record<string, string>;
|
|
19
|
-
/** Use the socket peer unless the gateway explicitly trusts its reverse proxy. */
|
|
20
|
-
export declare function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders?: boolean): string;
|
|
21
19
|
/** Keep configured gateway credentials out of URL and forwarded/logged request fields. */
|
|
22
20
|
export declare function hasMisplacedBearer(req: Request, url: URL, tokens: ReadonlySet<string>): boolean;
|
|
23
21
|
/**
|
|
@@ -10,6 +10,16 @@ export declare class MissingApiKeyError extends Error {
|
|
|
10
10
|
readonly provider: string | undefined;
|
|
11
11
|
constructor(provider?: string, message?: string);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Every usable OAuth credential for a provider failed to refresh with a
|
|
15
|
+
* retryable error (network blip, timeout, 5xx). The stored credentials are
|
|
16
|
+
* intact, so this is classified transient: retrying the request can succeed,
|
|
17
|
+
* unlike {@link MissingApiKeyError}. The refresh failure is kept as `cause`.
|
|
18
|
+
*/
|
|
19
|
+
export declare class OAuthRefreshUnavailableError extends Error {
|
|
20
|
+
readonly provider: string;
|
|
21
|
+
constructor(provider: string, cause: unknown);
|
|
22
|
+
}
|
|
13
23
|
/** A user-facing login flow required an `onPrompt` callback that was not supplied. */
|
|
14
24
|
export declare class OnPromptRequiredError extends Error {
|
|
15
25
|
constructor(providerLabel: string);
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* siblings are endpoint-scoped and stay: `strictToolsDisabled`
|
|
19
19
|
* (grammar-too-large 400 for the model's tool schema),
|
|
20
20
|
* `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
|
|
21
|
-
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks
|
|
22
|
-
*
|
|
21
|
+
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
|
|
22
|
+
* 400 made the client strip).
|
|
23
23
|
* - **OpenAI Responses** — the `previous_response_id` chain baselines are
|
|
24
24
|
* account-scoped: a stored response belongs to the account that created it.
|
|
25
25
|
* Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
|
|
@@ -13,7 +13,7 @@ export declare function pollCursorAuth(uuid: string, verifier: string): Promise<
|
|
|
13
13
|
}>;
|
|
14
14
|
export declare function loginCursor(onAuthUrl: (url: string) => void, onPollStart?: () => void): Promise<OAuthCredentials>;
|
|
15
15
|
export declare function loginCursorHook(callbacks: OAuthController): Promise<OAuthCredentials>;
|
|
16
|
-
export declare function refreshCursorToken(
|
|
16
|
+
export declare function refreshCursorToken(refreshToken: string): Promise<OAuthCredentials>;
|
|
17
17
|
export declare function refreshCursorHook(credentials: OAuthCredentials, signal?: AbortSignal): Promise<OAuthCredentials>;
|
|
18
18
|
/** Request headers that present `accessToken` to cursor.com as its user's web session. */
|
|
19
19
|
export declare function cursorSessionHeaders(userId: string, accessToken: string): Record<string, string>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.8.
|
|
3
|
+
"version": "18.8.4",
|
|
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.8.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
158
|
+
"@oh-my-pi/omptype": "18.8.4",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.8.4",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.8.4",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.8.4",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.8.4"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/auth/cascade.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
|
|
2
|
-
import { $env, $envExact } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { $env, $envExact, logger } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
|
|
4
4
|
import { getEnvApiKey, getEnvApiKeyName } from "../env-api-key";
|
|
5
5
|
import * as AIError from "../error";
|
|
@@ -7,7 +7,7 @@ import { isUsageLimitOutcome } from "../error/rate-limit";
|
|
|
7
7
|
import { AUTHENTICATED_SENTINEL } from "../registry/types";
|
|
8
8
|
import type { SessionAffinity } from "./affinity";
|
|
9
9
|
import type { CredentialPool } from "./pool";
|
|
10
|
-
import type { CredentialSelector } from "./select";
|
|
10
|
+
import type { CredentialSelector, OAuthResolutionResult } from "./select";
|
|
11
11
|
import type {
|
|
12
12
|
AuthApiKeyOptions,
|
|
13
13
|
AuthCredential,
|
|
@@ -308,7 +308,13 @@ export class KeyCascade implements KeysApi {
|
|
|
308
308
|
return undefined;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Resolve a bearer together with the stored row that supplied it, for a request.
|
|
313
|
+
* Same precedence as {@link KeyCascade.get}, but rejects with
|
|
314
|
+
* {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
|
|
315
|
+
* refresh failed with a retryable error and no other permitted source could serve
|
|
316
|
+
* the request, so request paths retry instead of reporting a missing key.
|
|
317
|
+
*/
|
|
312
318
|
async getWithCredential(
|
|
313
319
|
provider: string,
|
|
314
320
|
sessionId?: string,
|
|
@@ -316,7 +322,7 @@ export class KeyCascade implements KeysApi {
|
|
|
316
322
|
): Promise<ResolvedApiKey | undefined> {
|
|
317
323
|
let credentialId: number | undefined;
|
|
318
324
|
let oauthIdentity: OAuthRequestIdentity | undefined;
|
|
319
|
-
const apiKey = await this
|
|
325
|
+
const apiKey = await this.#resolve(provider, sessionId, options, (id, identity) => {
|
|
320
326
|
credentialId = id;
|
|
321
327
|
oauthIdentity = identity;
|
|
322
328
|
});
|
|
@@ -331,8 +337,21 @@ export class KeyCascade implements KeysApi {
|
|
|
331
337
|
* OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
|
|
332
338
|
* can serve or a config key (a models.yml `apiKey`, often for a proxy
|
|
333
339
|
* `baseUrl`) owns the provider, rather than using any other credential.
|
|
340
|
+
* A transient OAuth refresh failure resolves `undefined`, so availability
|
|
341
|
+
* probes move on to their next candidate; request paths use
|
|
342
|
+
* {@link KeyCascade.getWithCredential}, which surfaces it.
|
|
334
343
|
*/
|
|
335
|
-
async get(
|
|
344
|
+
async get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
|
|
345
|
+
try {
|
|
346
|
+
return await this.#resolve(provider, sessionId, options);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
|
|
349
|
+
logger.debug("API key probe skipped transient OAuth refresh failure", { provider, error: error.message });
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async #resolve(
|
|
336
355
|
provider: string,
|
|
337
356
|
sessionId?: string,
|
|
338
357
|
options?: AuthApiKeyOptions,
|
|
@@ -363,7 +382,16 @@ export class KeyCascade implements KeysApi {
|
|
|
363
382
|
|
|
364
383
|
// Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
|
|
365
384
|
// then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
366
|
-
|
|
385
|
+
// A transient OAuth refresh failure still lets later sources serve the request; it is
|
|
386
|
+
// rethrown only when none can, so callers retry instead of reporting a missing key.
|
|
387
|
+
let oauthResolved: OAuthResolutionResult | undefined;
|
|
388
|
+
let oauthRefreshFailure: AIError.OAuthRefreshUnavailableError | undefined;
|
|
389
|
+
try {
|
|
390
|
+
oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
|
|
393
|
+
oauthRefreshFailure = error;
|
|
394
|
+
}
|
|
367
395
|
if (oauthResolved) {
|
|
368
396
|
if (onCredentialId && oauthResolved.credentialId !== undefined) {
|
|
369
397
|
const { orgId, region, inferenceRegion } = oauthResolved.credential;
|
|
@@ -372,6 +400,7 @@ export class KeyCascade implements KeysApi {
|
|
|
372
400
|
return oauthResolved.apiKey;
|
|
373
401
|
}
|
|
374
402
|
if (restricted) {
|
|
403
|
+
if (oauthRefreshFailure) throw oauthRefreshFailure;
|
|
375
404
|
throw new AIError.MissingApiKeyError(
|
|
376
405
|
provider,
|
|
377
406
|
`No API key for provider: ${provider} (session ${sessionId} is restricted to its OAuth account pool and none of those accounts is available)`,
|
|
@@ -420,6 +449,7 @@ export class KeyCascade implements KeysApi {
|
|
|
420
449
|
if (apiKey !== undefined && credentialId !== undefined) onCredentialId?.(credentialId);
|
|
421
450
|
return apiKey;
|
|
422
451
|
}
|
|
452
|
+
if (oauthRefreshFailure) throw oauthRefreshFailure;
|
|
423
453
|
return undefined;
|
|
424
454
|
}
|
|
425
455
|
|
package/src/auth/oauth.ts
CHANGED
|
@@ -127,9 +127,11 @@ export class OAuthAccounts implements OAuthApi {
|
|
|
127
127
|
* `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
|
|
128
128
|
* scenarios, prefer API-key resolution.
|
|
129
129
|
*
|
|
130
|
-
* Returns `undefined` when no OAuth credential is available
|
|
131
|
-
*
|
|
132
|
-
* OAuth with an explicit API key.
|
|
130
|
+
* Returns `undefined` when no usable OAuth credential is available
|
|
131
|
+
* (none stored, or every one definitively failed to refresh) or
|
|
132
|
+
* runtime/config overrides have replaced OAuth with an explicit API key.
|
|
133
|
+
* Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
|
|
134
|
+
* retryable) when a retryable refresh failure left no usable credential.
|
|
133
135
|
*/
|
|
134
136
|
async access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined> {
|
|
135
137
|
// Runtime / config overrides intentionally short-circuit OAuth: when the
|
package/src/auth/resets.ts
CHANGED
|
@@ -262,7 +262,7 @@ export class ResetCredits implements ResetsApi {
|
|
|
262
262
|
resetSpentCredentialId: access.credentialId,
|
|
263
263
|
});
|
|
264
264
|
if (this.#deps.store.invalidateUsageCache) {
|
|
265
|
-
await this.#deps.store.invalidateUsageCache(options.signal).catch(err => {
|
|
265
|
+
await this.#deps.store.invalidateUsageCache(provider, options.signal).catch(err => {
|
|
266
266
|
logger.debug("Failed to notify store of stale usage", { err });
|
|
267
267
|
});
|
|
268
268
|
}
|
package/src/auth/select.ts
CHANGED
|
@@ -65,6 +65,8 @@ export type TryOAuthOptions = {
|
|
|
65
65
|
blockScopes?: readonly string[];
|
|
66
66
|
/** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
|
|
67
67
|
allowFallback?: boolean;
|
|
68
|
+
/** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
|
|
69
|
+
onTransientRefreshFailure?: (error: unknown) => void;
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
/** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
|
|
@@ -653,6 +655,14 @@ export class CredentialSelector {
|
|
|
653
655
|
: { selection, usage: null, usageChecked: false },
|
|
654
656
|
);
|
|
655
657
|
const preflightFailures = new Set<OAuthCandidate>();
|
|
658
|
+
// The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
|
|
659
|
+
// When no candidate resolves, it is rethrown so callers retry instead of reporting
|
|
660
|
+
// a missing key. Refresher outcomes after a dead grant (row disabled, CAS lost) are
|
|
661
|
+
// classified auth failures and keep resolving to undefined.
|
|
662
|
+
let transientRefreshFailure: unknown;
|
|
663
|
+
const recordTransientRefreshFailure = (error: unknown): void => {
|
|
664
|
+
if (AIError.retriable(AIError.classify(error))) transientRefreshFailure = error;
|
|
665
|
+
};
|
|
656
666
|
|
|
657
667
|
const sessionPreferredCandidate = candidates.findIndex(
|
|
658
668
|
candidate =>
|
|
@@ -799,16 +809,21 @@ export class CredentialSelector {
|
|
|
799
809
|
// to a sibling account with the wrong prefetched usage/plan.
|
|
800
810
|
return;
|
|
801
811
|
}
|
|
802
|
-
} else
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
this.#deps.
|
|
806
|
-
provider
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
+
} else {
|
|
813
|
+
recordTransientRefreshFailure(error);
|
|
814
|
+
if (credentialId !== undefined) {
|
|
815
|
+
const latestIndex = this.#deps.pool
|
|
816
|
+
.entries(provider)
|
|
817
|
+
.findIndex(entry => entry.id === credentialId);
|
|
818
|
+
if (latestIndex !== -1) {
|
|
819
|
+
this.#deps.blocks.mark(
|
|
820
|
+
provider,
|
|
821
|
+
providerKey,
|
|
822
|
+
latestIndex,
|
|
823
|
+
Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
|
|
824
|
+
AUTH_BLOCK_SCOPE,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
812
827
|
}
|
|
813
828
|
}
|
|
814
829
|
preflightFailures.add(candidate);
|
|
@@ -882,11 +897,15 @@ export class CredentialSelector {
|
|
|
882
897
|
rankingContext,
|
|
883
898
|
blockScope,
|
|
884
899
|
blockScopes,
|
|
900
|
+
onTransientRefreshFailure: recordTransientRefreshFailure,
|
|
885
901
|
});
|
|
886
902
|
if (resolved) return resolved;
|
|
887
903
|
}
|
|
888
904
|
}
|
|
889
905
|
|
|
906
|
+
if (transientRefreshFailure !== undefined) {
|
|
907
|
+
throw new AIError.OAuthRefreshUnavailableError(provider, transientRefreshFailure);
|
|
908
|
+
}
|
|
890
909
|
return undefined;
|
|
891
910
|
}
|
|
892
911
|
|
|
@@ -1117,6 +1136,7 @@ export class CredentialSelector {
|
|
|
1117
1136
|
if (allowFallback) return this.resolveOAuth(provider, sessionId, options);
|
|
1118
1137
|
}
|
|
1119
1138
|
} else {
|
|
1139
|
+
usageOptions.onTransientRefreshFailure?.(error);
|
|
1120
1140
|
// Block temporarily for transient failures (5 minutes)
|
|
1121
1141
|
this.#deps.blocks.mark(
|
|
1122
1142
|
provider,
|
package/src/auth/store.ts
CHANGED
|
@@ -152,8 +152,8 @@ export interface UsageLedgerStore {
|
|
|
152
152
|
|
|
153
153
|
/** Broker-delegated OAuth refresh and usage-report operations. */
|
|
154
154
|
export interface CredentialUpstream {
|
|
155
|
-
/** Optional hook to notify the underlying store that usage
|
|
156
|
-
invalidateUsageCache(signal?: AbortSignal): Promise<void>;
|
|
155
|
+
/** Optional hook to notify the underlying store that usage reports (one provider's, when given) are stale. */
|
|
156
|
+
invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
|
|
157
157
|
/**
|
|
158
158
|
* Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
|
|
159
159
|
* it before the per-provider local refresh path. `RemoteAuthCredentialStore`
|
package/src/auth/types.ts
CHANGED
|
@@ -894,9 +894,20 @@ export interface KeysApi {
|
|
|
894
894
|
* 4. API key persisted by a successful `/login`
|
|
895
895
|
* 5. Environment variable
|
|
896
896
|
* 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
|
|
897
|
+
*
|
|
898
|
+
* Resolves `undefined` when no permitted credential can serve, including when
|
|
899
|
+
* OAuth refresh failed transiently (network, timeout, 5xx), so availability
|
|
900
|
+
* probes move on to their next candidate. Request paths use
|
|
901
|
+
* {@link KeysApi.getWithCredential}, which surfaces that failure.
|
|
897
902
|
*/
|
|
898
903
|
get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
|
|
899
|
-
/**
|
|
904
|
+
/**
|
|
905
|
+
* Resolve a bearer together with its durable stored credential row id, when known.
|
|
906
|
+
* Same precedence as {@link KeysApi.get}, but rejects with
|
|
907
|
+
* `OAuthRefreshUnavailableError` (transient, retryable) when OAuth refresh failed
|
|
908
|
+
* with a retryable error and no other permitted source could serve the request;
|
|
909
|
+
* the stored credential is kept.
|
|
910
|
+
*/
|
|
900
911
|
getWithCredential(
|
|
901
912
|
provider: string,
|
|
902
913
|
sessionId?: string,
|
|
@@ -1017,9 +1028,11 @@ export interface OAuthApi {
|
|
|
1017
1028
|
* `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
|
|
1018
1029
|
* scenarios, prefer {@link AuthStorage.keys.get}.
|
|
1019
1030
|
*
|
|
1020
|
-
* Returns `undefined` when no OAuth credential is available
|
|
1021
|
-
*
|
|
1022
|
-
* OAuth with an explicit API key.
|
|
1031
|
+
* Returns `undefined` when no usable OAuth credential is available (none
|
|
1032
|
+
* stored, or every one definitively failed to refresh) or runtime/config
|
|
1033
|
+
* overrides have replaced OAuth with an explicit API key. Rejects with
|
|
1034
|
+
* `OAuthRefreshUnavailableError` (transient, retryable) when a retryable
|
|
1035
|
+
* refresh failure left no usable credential.
|
|
1023
1036
|
*/
|
|
1024
1037
|
access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
|
|
1025
1038
|
/**
|
package/src/auth/usage.ts
CHANGED
|
@@ -871,7 +871,7 @@ export class UsageService implements UsageApi {
|
|
|
871
871
|
await this.#deps.cache.clearReports(provider, () => this.#collectUsageRequests());
|
|
872
872
|
|
|
873
873
|
if (this.#deps.store.invalidateUsageCache) {
|
|
874
|
-
await this.#deps.store.invalidateUsageCache(signal).catch(err => {
|
|
874
|
+
await this.#deps.store.invalidateUsageCache(provider, signal).catch(err => {
|
|
875
875
|
logger.debug("Failed to notify store of stale usage", { err });
|
|
876
876
|
});
|
|
877
877
|
}
|
|
@@ -309,8 +309,11 @@ export class AuthBrokerClient {
|
|
|
309
309
|
});
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse> {
|
|
313
|
-
|
|
312
|
+
notifyUsageStale(provider?: string, signal?: AbortSignal): Promise<UsageStaleResponse> {
|
|
313
|
+
const params = new URLSearchParams();
|
|
314
|
+
if (provider) params.set("provider", provider);
|
|
315
|
+
const path = `/v1/usage/stale${params.size > 0 ? `?${params.toString()}` : ""}`;
|
|
316
|
+
return this.#request<UsageStaleResponse>("POST", path, {
|
|
314
317
|
schema: "usageStaleResponseSchema",
|
|
315
318
|
signal,
|
|
316
319
|
});
|
|
@@ -1196,11 +1196,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1196
1196
|
}
|
|
1197
1197
|
}
|
|
1198
1198
|
|
|
1199
|
-
async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
|
|
1199
|
+
async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
|
|
1200
1200
|
this.#noteActivity();
|
|
1201
1201
|
this.#invalidateUsageCache();
|
|
1202
1202
|
try {
|
|
1203
|
-
await this.#client.notifyUsageStale(signal);
|
|
1203
|
+
await this.#client.notifyUsageStale(provider, signal);
|
|
1204
1204
|
} catch (err) {
|
|
1205
1205
|
logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
|
|
1206
1206
|
} finally {
|
|
@@ -14,6 +14,7 @@ import { type Type, type } from "@oh-my-pi/omptype";
|
|
|
14
14
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
15
15
|
import type { AuthCredentialSnapshotEntry, AuthStorage, StoredCredentialBlock } from "../auth-storage";
|
|
16
16
|
import { parseBind } from "../utils/parse-bind";
|
|
17
|
+
import { resolvePeer } from "../utils/resolve-peer";
|
|
17
18
|
import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
|
|
18
19
|
import type {
|
|
19
20
|
ClientUsageReportRequest,
|
|
@@ -62,6 +63,8 @@ export interface AuthBrokerServerOptions {
|
|
|
62
63
|
bind?: string;
|
|
63
64
|
/** Accept any of these bearer tokens. Empty disables auth (loopback only). */
|
|
64
65
|
bearerTokens: string[];
|
|
66
|
+
/** Honor forwarded peer headers only when the connecting proxy is trusted. Default false. */
|
|
67
|
+
trustProxyHeaders?: boolean;
|
|
65
68
|
/** Broker version string surfaced on `/v1/healthz`. */
|
|
66
69
|
version?: string;
|
|
67
70
|
/** Refresh credentials expiring within this window. Default 5 min. */
|
|
@@ -156,6 +159,24 @@ const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/;
|
|
|
156
159
|
const DISABLE_ROUTE = /^\/v1\/credential\/(\d+)\/disable$/;
|
|
157
160
|
const BLOCK_ROUTE = /^\/v1\/credential\/(\d+)\/block$/;
|
|
158
161
|
const BLOCKS_ROUTE = /^\/v1\/credential\/(\d+)\/blocks$/;
|
|
162
|
+
const STATIC_ROUTES: Record<string, true> = {
|
|
163
|
+
"/v1/snapshot/stream": true,
|
|
164
|
+
"/v1/snapshot": true,
|
|
165
|
+
"/v1/usage": true,
|
|
166
|
+
"/v1/usage/history": true,
|
|
167
|
+
"/v1/usage/observed": true,
|
|
168
|
+
"/v1/usage/clients": true,
|
|
169
|
+
"/v1/usage/stale": true,
|
|
170
|
+
"/v1/credentials/disabled": true,
|
|
171
|
+
"/v1/credential": true,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Only known routes reach logs verbatim; unknown paths may carry caller-supplied secrets. */
|
|
175
|
+
function loggablePath(pathname: string): string {
|
|
176
|
+
if (Object.hasOwn(STATIC_ROUTES, pathname)) return pathname;
|
|
177
|
+
const dynamic = [REFRESH_ROUTE, DISABLE_ROUTE, BLOCK_ROUTE, BLOCKS_ROUTE].some(route => route.test(pathname));
|
|
178
|
+
return dynamic ? pathname : "<unrouted>";
|
|
179
|
+
}
|
|
159
180
|
|
|
160
181
|
const MAX_SNAPSHOT_WAIT_MS = 30_000;
|
|
161
182
|
const DISABLED_NEXT_SWEEP_IN_MS = Number.MAX_SAFE_INTEGER;
|
|
@@ -808,20 +829,24 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
|
|
|
808
829
|
hostname: bind.hostname,
|
|
809
830
|
port: bind.port,
|
|
810
831
|
idleTimeout: DEFAULT_SERVER_IDLE_TIMEOUT_S,
|
|
811
|
-
fetch: async (req): Promise<Response> => {
|
|
832
|
+
fetch: async (req, server): Promise<Response> => {
|
|
812
833
|
const url = new URL(req.url);
|
|
813
834
|
const pathname = url.pathname;
|
|
814
|
-
const
|
|
815
|
-
req.headers.get("x-forwarded-for")?.split(",")[0].trim() || req.headers.get("x-real-ip") || "unknown";
|
|
835
|
+
const socketPeer = server.requestIP(req)?.address ?? "unknown";
|
|
816
836
|
try {
|
|
817
837
|
if (req.method === "GET" && pathname === "/v1/healthz") {
|
|
818
838
|
const body: HealthzResponse = { ok: true, version };
|
|
819
839
|
return json(200, body);
|
|
820
840
|
}
|
|
821
841
|
if (!isAuthorized(req, tokens)) {
|
|
822
|
-
logger.info("auth-broker request unauthorized", {
|
|
842
|
+
logger.info("auth-broker request unauthorized", {
|
|
843
|
+
method: req.method,
|
|
844
|
+
path: loggablePath(pathname),
|
|
845
|
+
peer: socketPeer,
|
|
846
|
+
});
|
|
823
847
|
return json(401, { error: "unauthorized" });
|
|
824
848
|
}
|
|
849
|
+
const peer = resolvePeer(req, socketPeer, opts.trustProxyHeaders);
|
|
825
850
|
if (req.method === "GET" && pathname === "/v1/snapshot/stream") {
|
|
826
851
|
return serveSnapshotStream(req, snapshotSource, streamHub, peer, streamKeepaliveMs);
|
|
827
852
|
}
|
|
@@ -887,9 +912,10 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
|
|
|
887
912
|
return json(200, { generatedAt: Date.now(), clients: summary.clients });
|
|
888
913
|
}
|
|
889
914
|
if (req.method === "POST" && pathname === "/v1/usage/stale") {
|
|
915
|
+
const provider = url.searchParams.get("provider") || undefined;
|
|
890
916
|
try {
|
|
891
|
-
await opts.storage.usage.invalidate?.();
|
|
892
|
-
logger.info("auth-broker usage cache invalidated", { peer });
|
|
917
|
+
await opts.storage.usage.invalidate?.(provider);
|
|
918
|
+
logger.info("auth-broker usage cache invalidated", { peer, provider });
|
|
893
919
|
return json(200, { ok: true });
|
|
894
920
|
} catch (error) {
|
|
895
921
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1046,7 +1072,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
|
|
|
1046
1072
|
} catch (error) {
|
|
1047
1073
|
logger.error("auth-broker handler crashed", {
|
|
1048
1074
|
method: req.method,
|
|
1049
|
-
path: pathname,
|
|
1075
|
+
path: loggablePath(pathname),
|
|
1050
1076
|
error: String(error),
|
|
1051
1077
|
});
|
|
1052
1078
|
return json(500, { error: "internal error" });
|
|
@@ -32,6 +32,8 @@ export interface AuthGatewayRouteOptions {
|
|
|
32
32
|
resolveModel: ModelResolver;
|
|
33
33
|
/** Optional supplier for `/v1/models` listing. Returns the full model array. */
|
|
34
34
|
listModels?: () => Iterable<Model<Api>>;
|
|
35
|
+
/** Providers the host does not serve; `/v1/usage` and `/v1/credentials/check` leave their accounts out. */
|
|
36
|
+
excludeProviders?: ReadonlySet<string>;
|
|
35
37
|
/** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
|
|
36
38
|
fetch?: FetchImpl;
|
|
37
39
|
}
|
package/src/auth-gateway/http.ts
CHANGED
|
@@ -51,14 +51,6 @@ export function gatewayResponseHeaders(
|
|
|
51
51
|
return headers;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** Use the socket peer unless the gateway explicitly trusts its reverse proxy. */
|
|
55
|
-
export function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders = false): string {
|
|
56
|
-
if (!trustProxyHeaders) return socketAddress;
|
|
57
|
-
const fwd = req.headers.get("x-forwarded-for");
|
|
58
|
-
if (fwd) return fwd.split(",")[0].trim();
|
|
59
|
-
return req.headers.get("x-real-ip") ?? socketAddress;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
54
|
/**
|
|
63
55
|
* Decode each run of percent-escapes on its own, so one malformed escape
|
|
64
56
|
* elsewhere in the URL cannot hide an encoded token. A run that is not valid
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
32
32
|
import { type ModelKind, modelKind } from "@oh-my-pi/pi-catalog/types";
|
|
33
33
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
34
|
-
import type { AuthStorage } from "../auth-storage";
|
|
35
34
|
import { classifyGatewayError } from "../error/gateway";
|
|
36
35
|
import * as anthropicMessages from "../providers/anthropic-messages-server";
|
|
37
36
|
import * as openaiChat from "../providers/openai-chat-server";
|
|
@@ -41,6 +40,7 @@ import { completeSimple, streamSimple } from "../stream";
|
|
|
41
40
|
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
|
|
42
41
|
import { deterministicUuid } from "../utils/deterministic-id";
|
|
43
42
|
import { parseBind } from "../utils/parse-bind";
|
|
43
|
+
import { resolvePeer } from "../utils/resolve-peer";
|
|
44
44
|
import {
|
|
45
45
|
type AuthGatewayBootOptions,
|
|
46
46
|
type AuthGatewayRouteOptions,
|
|
@@ -59,7 +59,6 @@ import {
|
|
|
59
59
|
isAuthorized,
|
|
60
60
|
json,
|
|
61
61
|
resolveClientIdentity,
|
|
62
|
-
resolvePeer,
|
|
63
62
|
withCors,
|
|
64
63
|
} from "./http";
|
|
65
64
|
import { handleEmbeddings } from "./routes/embeddings";
|
|
@@ -709,12 +708,14 @@ async function handlePiNative(
|
|
|
709
708
|
* failure) inside `AuthStorage`, so this handler is a thin wrapper that
|
|
710
709
|
* surfaces the same data to HTTP callers (notably the macOS usage widget).
|
|
711
710
|
*/
|
|
712
|
-
async function handleUsage(
|
|
713
|
-
const reports = (await storage.usage.reports?.({ signal })) ?? [];
|
|
711
|
+
async function handleUsage(opts: AuthGatewayRouteOptions, signal: AbortSignal): Promise<Response> {
|
|
712
|
+
const reports = (await opts.storage.usage.reports?.({ signal })) ?? [];
|
|
714
713
|
// Drop the heavy provider-specific `raw` payload — UI consumers only need
|
|
715
714
|
// `limits` + `metadata`. Match the broker's `/v1/usage` shape so a single
|
|
716
715
|
// client struct (Swift widget, llm-git, ...) works against either endpoint.
|
|
717
|
-
const trimmed = reports
|
|
716
|
+
const trimmed = reports
|
|
717
|
+
.filter(report => !opts.excludeProviders?.has(report.provider))
|
|
718
|
+
.map(({ raw: _raw, ...rest }) => rest);
|
|
718
719
|
return json(200, { generatedAt: Date.now(), reports: trimmed });
|
|
719
720
|
}
|
|
720
721
|
|
|
@@ -729,8 +730,8 @@ async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<R
|
|
|
729
730
|
* endpoints. For multi-account pools that's the difference between getting
|
|
730
731
|
* a clean diagnosis and getting a 429 storm.
|
|
731
732
|
*/
|
|
732
|
-
async function handleCredentialsCheck(
|
|
733
|
-
const credentials = await storage.health.check({ signal });
|
|
733
|
+
async function handleCredentialsCheck(opts: AuthGatewayRouteOptions, signal: AbortSignal): Promise<Response> {
|
|
734
|
+
const credentials = await opts.storage.health.check({ signal, excludeProviders: opts.excludeProviders });
|
|
734
735
|
return json(200, { generatedAt: Date.now(), credentials });
|
|
735
736
|
}
|
|
736
737
|
|
|
@@ -832,14 +833,14 @@ export function createAuthGatewayRouter(opts: AuthGatewayRouteOptions): AuthGate
|
|
|
832
833
|
// Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
|
|
833
834
|
// same client struct.
|
|
834
835
|
if (req.method === "GET" && pathname === "/v1/usage") {
|
|
835
|
-
return await handleUsage(opts
|
|
836
|
+
return await handleUsage(opts, req.signal);
|
|
836
837
|
}
|
|
837
838
|
|
|
838
839
|
// Per-credential auth probe — diagnoses which row in a multi-account
|
|
839
840
|
// pool is producing 401s. Aggregated `/v1/usage` silently drops failed
|
|
840
841
|
// credentials, so we need a separate endpoint that captures errors.
|
|
841
842
|
if (req.method === "GET" && pathname === "/v1/credentials/check") {
|
|
842
|
-
return await handleCredentialsCheck(opts
|
|
843
|
+
return await handleCredentialsCheck(opts, req.signal);
|
|
843
844
|
}
|
|
844
845
|
|
|
845
846
|
// Provider-format dispatch.
|
package/src/error/auth.ts
CHANGED
|
@@ -19,6 +19,26 @@ export class MissingApiKeyError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Every usable OAuth credential for a provider failed to refresh with a
|
|
24
|
+
* retryable error (network blip, timeout, 5xx). The stored credentials are
|
|
25
|
+
* intact, so this is classified transient: retrying the request can succeed,
|
|
26
|
+
* unlike {@link MissingApiKeyError}. The refresh failure is kept as `cause`.
|
|
27
|
+
*/
|
|
28
|
+
export class OAuthRefreshUnavailableError extends Error {
|
|
29
|
+
readonly provider: string;
|
|
30
|
+
|
|
31
|
+
constructor(provider: string, cause: unknown) {
|
|
32
|
+
super(
|
|
33
|
+
`OAuth token refresh failed for provider ${provider}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
34
|
+
{ cause },
|
|
35
|
+
);
|
|
36
|
+
this.name = "OAuthRefreshUnavailableError";
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
attach(this, create(Flag.Transient));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/** A user-facing login flow required an `onPrompt` callback that was not supplied. */
|
|
23
43
|
export class OnPromptRequiredError extends Error {
|
|
24
44
|
constructor(providerLabel: string) {
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* siblings are endpoint-scoped and stay: `strictToolsDisabled`
|
|
19
19
|
* (grammar-too-large 400 for the model's tool schema),
|
|
20
20
|
* `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
|
|
21
|
-
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks
|
|
22
|
-
*
|
|
21
|
+
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
|
|
22
|
+
* 400 made the client strip).
|
|
23
23
|
* - **OpenAI Responses** — the `previous_response_id` chain baselines are
|
|
24
24
|
* account-scoped: a stored response belongs to the account that created it.
|
|
25
25
|
* Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
|
|
@@ -486,8 +486,14 @@ type AnthropicProviderSessionState = ProviderSessionState & {
|
|
|
486
486
|
* this (baseUrl, modelId). Cleared on session close.
|
|
487
487
|
*/
|
|
488
488
|
thinkingReplayDisabled: boolean;
|
|
489
|
-
/** Thinking blocks
|
|
489
|
+
/** Thinking blocks stripped after a prefix-binding 400 forced a retry without `drop_block`. */
|
|
490
490
|
prefixDroppedThinkingBlocks: Set<string>;
|
|
491
|
+
/**
|
|
492
|
+
* Server-reported `prefix_binding_mismatch` drops already warned about. A
|
|
493
|
+
* `drop_block` request replays dropped blocks verbatim, so the API reports
|
|
494
|
+
* them again on every later request; only newly dropped blocks warn.
|
|
495
|
+
*/
|
|
496
|
+
reportedPrefixDroppedThinking: Set<string>;
|
|
491
497
|
};
|
|
492
498
|
|
|
493
499
|
function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
|
|
@@ -497,12 +503,14 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
|
|
|
497
503
|
replayUnsignedThinkingDisabled: false,
|
|
498
504
|
thinkingReplayDisabled: false,
|
|
499
505
|
prefixDroppedThinkingBlocks: new Set(),
|
|
506
|
+
reportedPrefixDroppedThinking: new Set(),
|
|
500
507
|
close: () => {
|
|
501
508
|
state.strictToolsDisabled = false;
|
|
502
509
|
state.fastModeDisabled = false;
|
|
503
510
|
state.replayUnsignedThinkingDisabled = false;
|
|
504
511
|
state.thinkingReplayDisabled = false;
|
|
505
512
|
state.prefixDroppedThinkingBlocks.clear();
|
|
513
|
+
state.reportedPrefixDroppedThinking.clear();
|
|
506
514
|
},
|
|
507
515
|
};
|
|
508
516
|
return state;
|
|
@@ -518,6 +526,7 @@ function getAnthropicProviderSessionState(
|
|
|
518
526
|
const existing = providerSessionState.get(key) as AnthropicProviderSessionState | undefined;
|
|
519
527
|
if (existing) {
|
|
520
528
|
existing.prefixDroppedThinkingBlocks ??= new Set();
|
|
529
|
+
existing.reportedPrefixDroppedThinking ??= new Set();
|
|
521
530
|
return existing;
|
|
522
531
|
}
|
|
523
532
|
const created = createAnthropicProviderSessionState();
|
|
@@ -1943,12 +1952,33 @@ function rememberPrefixBindingFailure(
|
|
|
1943
1952
|
return true;
|
|
1944
1953
|
}
|
|
1945
1954
|
|
|
1955
|
+
/** Identify a reported drop by the dropped block's replay key, falling back to its wire path. */
|
|
1956
|
+
function reportedDropKey(params: MessageCreateParamsStreaming, path: string | undefined): string | undefined {
|
|
1957
|
+
if (path === undefined) return undefined;
|
|
1958
|
+
const match = INPUT_TRANSFORMATION_PATH_PATTERN.exec(path);
|
|
1959
|
+
if (match) {
|
|
1960
|
+
const content = params.messages[Number(match[1])]?.content;
|
|
1961
|
+
const block = Array.isArray(content) ? content[Number(match[2])] : undefined;
|
|
1962
|
+
const key = block ? thinkingReplayKey(block) : undefined;
|
|
1963
|
+
if (key) return key;
|
|
1964
|
+
}
|
|
1965
|
+
return `path:${path}`;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* Record server-reported input rewrites on the response. A `drop_block`
|
|
1970
|
+
* request keeps replaying the transcript verbatim afterwards: the API drops
|
|
1971
|
+
* the same invalid blocks again on every later request (unbilled), while
|
|
1972
|
+
* omitting them client-side would change the bytes the drop request just
|
|
1973
|
+
* cached and can cost another message-prefix cache miss from the first
|
|
1974
|
+
* dropped block on. Repeat reports of an already-warned drop log at debug.
|
|
1975
|
+
*/
|
|
1946
1976
|
function applyReportedInputTransformations(
|
|
1947
1977
|
output: AssistantMessage,
|
|
1948
|
-
params: MessageCreateParamsStreaming,
|
|
1949
|
-
state: AnthropicProviderSessionState | undefined,
|
|
1950
1978
|
value: unknown,
|
|
1951
1979
|
seen: Set<string>,
|
|
1980
|
+
params: MessageCreateParamsStreaming,
|
|
1981
|
+
state: AnthropicProviderSessionState | undefined,
|
|
1952
1982
|
replace = false,
|
|
1953
1983
|
): void {
|
|
1954
1984
|
if (value === undefined || value === null) return;
|
|
@@ -1965,14 +1995,28 @@ function applyReportedInputTransformations(
|
|
|
1965
1995
|
}
|
|
1966
1996
|
if (fresh.length === 0) return;
|
|
1967
1997
|
output.inputTransformations = [...(output.inputTransformations ?? []), ...fresh];
|
|
1968
|
-
|
|
1998
|
+
let repeated = 0;
|
|
1969
1999
|
for (const transformation of fresh) {
|
|
1970
2000
|
if (transformation.reason !== "prefix_binding_mismatch") continue;
|
|
2001
|
+
const dropKey = reportedDropKey(params, transformation.path);
|
|
2002
|
+
if (state && dropKey !== undefined) {
|
|
2003
|
+
if (state.reportedPrefixDroppedThinking.has(dropKey)) {
|
|
2004
|
+
repeated++;
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
state.reportedPrefixDroppedThinking.add(dropKey);
|
|
2008
|
+
}
|
|
1971
2009
|
logger.warn("anthropic: dropped thinking block after conversation prefix changed", {
|
|
1972
2010
|
model: output.model,
|
|
1973
2011
|
path: transformation.path,
|
|
1974
2012
|
});
|
|
1975
2013
|
}
|
|
2014
|
+
if (repeated > 0) {
|
|
2015
|
+
logger.debug("anthropic: API re-dropped previously reported thinking blocks", {
|
|
2016
|
+
model: output.model,
|
|
2017
|
+
count: repeated,
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
1976
2020
|
}
|
|
1977
2021
|
|
|
1978
2022
|
/**
|
|
@@ -2661,10 +2705,10 @@ const streamAnthropicOnce = (
|
|
|
2661
2705
|
if (startMessage?.id) output.responseId = startMessage.id;
|
|
2662
2706
|
applyReportedInputTransformations(
|
|
2663
2707
|
output,
|
|
2664
|
-
params,
|
|
2665
|
-
providerSessionState,
|
|
2666
2708
|
startMessage?.input_transformations,
|
|
2667
2709
|
seenInputTransformations,
|
|
2710
|
+
params,
|
|
2711
|
+
providerSessionState,
|
|
2668
2712
|
);
|
|
2669
2713
|
const startUsage = startMessage?.usage;
|
|
2670
2714
|
if (startUsage) {
|
|
@@ -2978,10 +3022,10 @@ const streamAnthropicOnce = (
|
|
|
2978
3022
|
const delta = event.delta;
|
|
2979
3023
|
applyReportedInputTransformations(
|
|
2980
3024
|
output,
|
|
2981
|
-
params,
|
|
2982
|
-
providerSessionState,
|
|
2983
3025
|
event.input_transformations,
|
|
2984
3026
|
seenInputTransformations,
|
|
3027
|
+
params,
|
|
3028
|
+
providerSessionState,
|
|
2985
3029
|
true,
|
|
2986
3030
|
);
|
|
2987
3031
|
const rawStopReason = delta?.stop_reason;
|
package/src/providers/cursor.ts
CHANGED
|
@@ -611,7 +611,7 @@ function classifyCursorStructuredError(structured: CursorStructuredError): Error
|
|
|
611
611
|
if (code === 21) return new AIError.AbortError(fullMessage);
|
|
612
612
|
const status = CURSOR_ERROR_STATUS[code];
|
|
613
613
|
if (status !== undefined) return new AIError.ProviderHttpError(fullMessage, status, { code: name });
|
|
614
|
-
if (retryable === true || CURSOR_RETRYABLE_ERROR_CODES.has(code)) {
|
|
614
|
+
if (retryable === true || (retryable === undefined && CURSOR_RETRYABLE_ERROR_CODES.has(code))) {
|
|
615
615
|
return new AIError.ProviderHttpError(fullMessage, 503, { code: name });
|
|
616
616
|
}
|
|
617
617
|
return new AIError.ProviderHttpError(fullMessage, 400, { code: name });
|
|
@@ -1089,6 +1089,9 @@ function streamCursorWithWireMode(
|
|
|
1089
1089
|
const h2Completion = Promise.withResolvers<void>();
|
|
1090
1090
|
let h2Settled = false;
|
|
1091
1091
|
let sawTurnEnded = false;
|
|
1092
|
+
// After the final step's `stepCompleted`, only a repeat checkpoint and the
|
|
1093
|
+
// `turnEnded` usage frame follow.
|
|
1094
|
+
let stepCompletedIsLatest = false;
|
|
1092
1095
|
let endStreamError: Error | null = null;
|
|
1093
1096
|
let progressVersion = 0;
|
|
1094
1097
|
let latestCheckpointProgressVersion = -1;
|
|
@@ -1115,6 +1118,20 @@ function streamCursorWithWireMode(
|
|
|
1115
1118
|
return;
|
|
1116
1119
|
}
|
|
1117
1120
|
if (!sawTurnEnded) {
|
|
1121
|
+
// A stream cut after the final step lost only usage and a repeat
|
|
1122
|
+
// checkpoint, so the turn is complete. The final step ends on answer
|
|
1123
|
+
// text; a step that ended on tool calls, or left one open, may be
|
|
1124
|
+
// followed by another step and is still treated as truncated.
|
|
1125
|
+
if (
|
|
1126
|
+
stepCompletedIsLatest &&
|
|
1127
|
+
output.content.at(-1)?.type === "text" &&
|
|
1128
|
+
openBlockState?.currentToolCall === null &&
|
|
1129
|
+
openBlockState.openToolCalls.size === 0
|
|
1130
|
+
) {
|
|
1131
|
+
logger.debug("cursor stream ended after stepCompleted without turnEnded", { model: model.id });
|
|
1132
|
+
h2Completion.resolve();
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1118
1135
|
h2Completion.reject(
|
|
1119
1136
|
new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
|
|
1120
1137
|
kind: "incomplete-stream",
|
|
@@ -1349,6 +1366,15 @@ function streamCursorWithWireMode(
|
|
|
1349
1366
|
sawProgressOrSideEffect = true;
|
|
1350
1367
|
progressVersion++;
|
|
1351
1368
|
}
|
|
1369
|
+
if (interactionCase === "stepCompleted") {
|
|
1370
|
+
stepCompletedIsLatest = true;
|
|
1371
|
+
} else if (
|
|
1372
|
+
interactionCase !== "heartbeat" &&
|
|
1373
|
+
serverMessage.message.case !== "conversationCheckpointUpdate" &&
|
|
1374
|
+
serverMessage.message.case !== "kvServerMessage"
|
|
1375
|
+
) {
|
|
1376
|
+
stepCompletedIsLatest = false;
|
|
1377
|
+
}
|
|
1352
1378
|
if (serverMessage.message.case === "conversationCheckpointUpdate") {
|
|
1353
1379
|
latestCheckpoint = serverMessage.message.value;
|
|
1354
1380
|
latestCheckpointProgressVersion = progressVersion;
|
|
@@ -6,7 +6,9 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
6
6
|
|
|
7
7
|
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
|
|
8
8
|
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
|
|
9
|
-
const CURSOR_REFRESH_URL = "https://api2.cursor.sh/
|
|
9
|
+
const CURSOR_REFRESH_URL = "https://api2.cursor.sh/oauth/token";
|
|
10
|
+
/** OAuth client the Cursor IDE renews its login session with. */
|
|
11
|
+
const CURSOR_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
|
|
10
12
|
const CURSOR_PROFILE_URL = "https://cursor.com/api/auth/me";
|
|
11
13
|
const CURSOR_PROFILE_TIMEOUT_MS = 3_000;
|
|
12
14
|
|
|
@@ -118,14 +120,11 @@ export async function loginCursorHook(callbacks: OAuthController): Promise<OAuth
|
|
|
118
120
|
return withCursorAccountEmail(credentials, callbacks.fetch ?? fetch, callbacks.signal);
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
export async function refreshCursorToken(
|
|
123
|
+
export async function refreshCursorToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
122
124
|
const response = await fetch(CURSOR_REFRESH_URL, {
|
|
123
125
|
method: "POST",
|
|
124
|
-
headers: {
|
|
125
|
-
|
|
126
|
-
"Content-Type": "application/json",
|
|
127
|
-
},
|
|
128
|
-
body: "{}",
|
|
126
|
+
headers: { "Content-Type": "application/json" },
|
|
127
|
+
body: JSON.stringify({ grant_type: "refresh_token", client_id: CURSOR_CLIENT_ID, refresh_token: refreshToken }),
|
|
129
128
|
});
|
|
130
129
|
|
|
131
130
|
if (!response.ok) {
|
|
@@ -136,17 +135,27 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
|
|
|
136
135
|
});
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
const data = (await response.json()) as {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
const data = (await response.json()) as { access_token?: string; refresh_token?: string; shouldLogout?: boolean };
|
|
139
|
+
// Cursor answers a session it will not renew with 200, an empty token and `shouldLogout`.
|
|
140
|
+
if (data.shouldLogout === true) {
|
|
141
|
+
throw new AIError.OAuthError("invalid_grant: Cursor ended this session; run /login cursor again", {
|
|
142
|
+
kind: "token-refresh",
|
|
143
|
+
provider: "cursor",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!data.access_token) {
|
|
147
|
+
throw new AIError.OAuthError("Cursor token refresh returned no access token", {
|
|
148
|
+
kind: "token-refresh",
|
|
149
|
+
provider: "cursor",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
145
152
|
|
|
153
|
+
// Cursor's renewal answers only an access token; the IDE keeps its stored refresh token, so do the same
|
|
154
|
+
// unless a rotated one is returned.
|
|
146
155
|
return {
|
|
147
|
-
access: data.
|
|
148
|
-
refresh: data.
|
|
149
|
-
expires:
|
|
156
|
+
access: data.access_token,
|
|
157
|
+
refresh: data.refresh_token || refreshToken,
|
|
158
|
+
expires: getTokenExpiry(data.access_token),
|
|
150
159
|
};
|
|
151
160
|
}
|
|
152
161
|
|
|
@@ -272,7 +272,7 @@ function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
|
|
|
272
272
|
? `${data.timePeriod.year}-${String(data.timePeriod.month).padStart(2, "0")}`
|
|
273
273
|
: `${data.timePeriod.year}`;
|
|
274
274
|
const window: UsageWindow = {
|
|
275
|
-
id: "
|
|
275
|
+
id: "monthly",
|
|
276
276
|
label: periodLabel,
|
|
277
277
|
};
|
|
278
278
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Use the socket peer unless the server explicitly trusts its reverse proxy. */
|
|
2
|
+
export function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders = false): string {
|
|
3
|
+
if (!trustProxyHeaders) return socketAddress;
|
|
4
|
+
const fwd = req.headers.get("x-forwarded-for");
|
|
5
|
+
if (fwd) return fwd.split(",")[0].trim();
|
|
6
|
+
return req.headers.get("x-real-ip") ?? socketAddress;
|
|
7
|
+
}
|