@oh-my-pi/pi-ai 15.1.9 → 15.2.1

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.2.0] - 2026-05-21
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `/login` (and `/logout`, plus any `AuthStorage.set` / `remove` call) against a remote auth-broker throwing `RemoteAuthCredentialStore is read-only on the client. Use 'omp auth-broker login <provider>' to mutate credentials.` Added three optional async write hooks to `AuthCredentialStore` (`upsertAuthCredentialRemote`, `replaceAuthCredentialsRemote`, `deleteAuthCredentialsRemote`); `RemoteAuthCredentialStore` implements them via the broker's `POST /v1/credential` and `POST /v1/credential/:id/disable` endpoints and applies the broker's authoritative post-write entries to the local snapshot. `AuthStorage` routes through the hooks when present, so OAuth and API-key logins (and logouts) initiated from a broker-backed client now persist server-side and surface immediately without waiting for the long-poll snapshot tick.
10
+
5
11
  ## [15.1.9] - 2026-05-21
6
12
 
7
13
  ### Fixed
@@ -40,6 +40,26 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
40
40
  replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[];
41
41
  upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[];
42
42
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void;
43
+ /**
44
+ * Upsert a single credential through the broker. The broker server is the
45
+ * canonical writer — see `POST /v1/credential`. The redacted snapshot
46
+ * entries returned by the server replace the provider's rows in our local
47
+ * snapshot, and the global snapshot is then refreshed in the background so
48
+ * any concurrent peer (refresh, generation bump) stays in sync.
49
+ */
50
+ upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
51
+ /**
52
+ * Replace-all semantics: disable every active credential for the provider,
53
+ * then upload each of the new credentials. Used by API-key login so a new
54
+ * key clobbers any previously stored key for the same provider.
55
+ */
56
+ replaceAuthCredentialsRemote(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
57
+ /**
58
+ * Logout: disable every active credential for the provider on the broker,
59
+ * then drop them from the local snapshot. Refresh fetches the authoritative
60
+ * post-state in the background.
61
+ */
62
+ deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void>;
43
63
  getCache(key: string): string | null;
44
64
  setCache(key: string, value: string, expiresAtSec: number): void;
45
65
  cleanExpiredCache(): void;
@@ -152,6 +152,29 @@ export interface AuthCredentialStore {
152
152
  markCredentialSuspect?(credentialId: number, opts?: {
153
153
  signal?: AbortSignal;
154
154
  }): Promise<void>;
155
+ /**
156
+ * Optional async write hook for upserting a single credential. When present,
157
+ * `AuthStorage.#upsertOAuthCredential` routes through this instead of the
158
+ * sync `upsertAuthCredentialForProvider`. `RemoteAuthCredentialStore` uses
159
+ * it to send the upsert to the broker via `POST /v1/credential`.
160
+ *
161
+ * Implementations MUST update the in-memory snapshot before returning so the
162
+ * post-write read path is consistent.
163
+ */
164
+ upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
165
+ /**
166
+ * Optional async write hook for replace-all semantics (e.g. API-key login
167
+ * overwriting any previous keys for the same provider). When present,
168
+ * `AuthStorage.set` routes through this instead of the sync
169
+ * `replaceAuthCredentialsForProvider`.
170
+ */
171
+ replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
172
+ /**
173
+ * Optional async write hook for clearing every credential for a provider
174
+ * (logout). When present, `AuthStorage.remove` routes through this instead
175
+ * of the sync `deleteAuthCredentialsForProvider`.
176
+ */
177
+ deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise<void>;
155
178
  }
156
179
  /**
157
180
  * Event payload describing a credential that was just soft-disabled.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.1.9",
4
+ "version": "15.2.1",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@oh-my-pi/pi-utils": "15.1.9",
46
+ "@oh-my-pi/pi-utils": "15.2.1",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -11,6 +11,7 @@ import { scheduler } from "node:timers/promises";
11
11
  import { logger } from "@oh-my-pi/pi-utils";
12
12
  import {
13
13
  type AuthCredential,
14
+ type AuthCredentialSnapshotEntry,
14
15
  type AuthCredentialStore,
15
16
  type OAuthCredential,
16
17
  REMOTE_REFRESH_SENTINEL,
@@ -212,6 +213,94 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
212
213
  );
213
214
  }
214
215
 
216
+ /**
217
+ * Upsert a single credential through the broker. The broker server is the
218
+ * canonical writer — see `POST /v1/credential`. The redacted snapshot
219
+ * entries returned by the server replace the provider's rows in our local
220
+ * snapshot, and the global snapshot is then refreshed in the background so
221
+ * any concurrent peer (refresh, generation bump) stays in sync.
222
+ */
223
+ async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
224
+ const { entries } = await this.#client.uploadCredential(provider, credential);
225
+ this.#applyProviderEntries(provider, entries);
226
+ void this.refreshSnapshot().catch(error => {
227
+ logger.debug("auth-broker snapshot refresh after upload failed", { error: String(error) });
228
+ });
229
+ return this.listAuthCredentials(provider);
230
+ }
231
+
232
+ /**
233
+ * Replace-all semantics: disable every active credential for the provider,
234
+ * then upload each of the new credentials. Used by API-key login so a new
235
+ * key clobbers any previously stored key for the same provider.
236
+ */
237
+ async replaceAuthCredentialsRemote(
238
+ provider: string,
239
+ credentials: AuthCredential[],
240
+ ): Promise<StoredAuthCredential[]> {
241
+ const existing = this.listAuthCredentials(provider);
242
+ for (const entry of existing) {
243
+ try {
244
+ await this.#client.disableCredential(entry.id, "replaced by newer credential");
245
+ } catch (error) {
246
+ logger.warn("auth-broker disable during replace failed", {
247
+ provider,
248
+ id: entry.id,
249
+ error: String(error),
250
+ });
251
+ }
252
+ }
253
+ // Snapshot reflects the disables before we add the new rows so a concurrent
254
+ // reader cannot momentarily see old + new together for the same provider.
255
+ this.#removeProviderEntries(provider);
256
+ for (const credential of credentials) {
257
+ const { entries } = await this.#client.uploadCredential(provider, credential);
258
+ this.#applyProviderEntries(provider, entries);
259
+ }
260
+ void this.refreshSnapshot().catch(error => {
261
+ logger.debug("auth-broker snapshot refresh after replace failed", { error: String(error) });
262
+ });
263
+ return this.listAuthCredentials(provider);
264
+ }
265
+
266
+ /**
267
+ * Logout: disable every active credential for the provider on the broker,
268
+ * then drop them from the local snapshot. Refresh fetches the authoritative
269
+ * post-state in the background.
270
+ */
271
+ async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
272
+ const existing = this.listAuthCredentials(provider);
273
+ for (const entry of existing) {
274
+ try {
275
+ await this.#client.disableCredential(entry.id, disabledCause);
276
+ } catch (error) {
277
+ logger.warn("auth-broker disable during delete failed", {
278
+ provider,
279
+ id: entry.id,
280
+ error: String(error),
281
+ });
282
+ }
283
+ }
284
+ this.#removeProviderEntries(provider);
285
+ void this.refreshSnapshot().catch(error => {
286
+ logger.debug("auth-broker snapshot refresh after delete failed", { error: String(error) });
287
+ });
288
+ }
289
+
290
+ #applyProviderEntries(provider: string, entries: AuthCredentialSnapshotEntry[]): void {
291
+ // `entries` is the broker's authoritative post-upsert list of rows for
292
+ // `provider`. Drop our existing rows for the same provider and splice in
293
+ // the fresh set — preserving every other provider's rows in place.
294
+ const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
295
+ const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
296
+ this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
297
+ }
298
+
299
+ #removeProviderEntries(provider: string): void {
300
+ const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
301
+ this.#snapshot = { ...this.#snapshot, credentials: next };
302
+ }
303
+
215
304
  getCache(key: string): string | null {
216
305
  const entry = this.#cache.get(key);
217
306
  if (!entry) return null;
@@ -198,6 +198,29 @@ export interface AuthCredentialStore {
198
198
  * {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`.
199
199
  */
200
200
  markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<void>;
201
+ /**
202
+ * Optional async write hook for upserting a single credential. When present,
203
+ * `AuthStorage.#upsertOAuthCredential` routes through this instead of the
204
+ * sync `upsertAuthCredentialForProvider`. `RemoteAuthCredentialStore` uses
205
+ * it to send the upsert to the broker via `POST /v1/credential`.
206
+ *
207
+ * Implementations MUST update the in-memory snapshot before returning so the
208
+ * post-write read path is consistent.
209
+ */
210
+ upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
211
+ /**
212
+ * Optional async write hook for replace-all semantics (e.g. API-key login
213
+ * overwriting any previous keys for the same provider). When present,
214
+ * `AuthStorage.set` routes through this instead of the sync
215
+ * `replaceAuthCredentialsForProvider`.
216
+ */
217
+ replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
218
+ /**
219
+ * Optional async write hook for clearing every credential for a provider
220
+ * (logout). When present, `AuthStorage.remove` routes through this instead
221
+ * of the sync `deleteAuthCredentialsForProvider`.
222
+ */
223
+ deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise<void>;
201
224
  }
202
225
 
203
226
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1076,7 +1099,9 @@ export class AuthStorage {
1076
1099
  async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
1077
1100
  const normalized = Array.isArray(credential) ? credential : [credential];
1078
1101
  const deduped = this.#dedupeOAuthCredentials(provider, normalized);
1079
- const stored = this.#store.replaceAuthCredentialsForProvider(provider, deduped);
1102
+ const stored = this.#store.replaceAuthCredentialsRemote
1103
+ ? await this.#store.replaceAuthCredentialsRemote(provider, deduped)
1104
+ : this.#store.replaceAuthCredentialsForProvider(provider, deduped);
1080
1105
  this.#setStoredCredentials(
1081
1106
  provider,
1082
1107
  stored.map(record => ({ id: record.id, credential: record.credential })),
@@ -1085,7 +1110,9 @@ export class AuthStorage {
1085
1110
  }
1086
1111
 
1087
1112
  async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
1088
- const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
1113
+ const stored = this.#store.upsertAuthCredentialRemote
1114
+ ? await this.#store.upsertAuthCredentialRemote(provider, credential)
1115
+ : this.#store.upsertAuthCredentialForProvider(provider, credential);
1089
1116
  this.#setStoredCredentials(
1090
1117
  provider,
1091
1118
  stored.map(record => ({ id: record.id, credential: record.credential })),
@@ -1097,7 +1124,11 @@ export class AuthStorage {
1097
1124
  * Remove credential for a provider.
1098
1125
  */
1099
1126
  async remove(provider: string): Promise<void> {
1100
- this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
1127
+ if (this.#store.deleteAuthCredentialsRemote) {
1128
+ await this.#store.deleteAuthCredentialsRemote(provider, "deleted by user");
1129
+ } else {
1130
+ this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
1131
+ }
1101
1132
  this.#setStoredCredentials(provider, []);
1102
1133
  this.#resetProviderAssignments(provider);
1103
1134
  }
@@ -129,7 +129,11 @@ function formatCapturedHttpError(captured: CapturedHttpErrorResponse | undefined
129
129
  if (!payload) return bodyText;
130
130
 
131
131
  const errorPayload = getObjectProperty(payload, "error") ?? payload;
132
- const message = getStringProperty(errorPayload, "message") ?? getStringProperty(payload, "message") ?? bodyText;
132
+ // {"error": "string"} the error value is a plain string, not a nested object.
133
+ // Fall back to it when the structured fields ("message", etc.) are absent.
134
+ const stringError = errorPayload === payload ? getStringProperty(payload, "error") : undefined;
135
+ const message =
136
+ getStringProperty(errorPayload, "message") ?? getStringProperty(payload, "message") ?? stringError ?? bodyText;
133
137
  const extras = [
134
138
  getStringProperty(errorPayload, "type") ?? getStringProperty(payload, "type"),
135
139
  getStringProperty(errorPayload, "param") ?? getStringProperty(payload, "param"),
@@ -108,9 +108,12 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num
108
108
  return true;
109
109
  }
110
110
 
111
- // Cerebras and Mistral return 400/413 with no body for context overflow
111
+ // Cerebras and Mistral return 400/413 with no body for context overflow.
112
+ // Proxy providers (e.g. api.synthetic.new) wrap upstream 400/413 no-body
113
+ // responses in a JSON envelope, so the status code phrase may appear
114
+ // anywhere in the message rather than at its start.
112
115
  // Note: 429 is rate limiting (requests/tokens per time), NOT context overflow
113
- if (/^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) {
116
+ if (/\b4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) {
114
117
  return true;
115
118
  }
116
119
  }