@oh-my-pi/pi-ai 16.0.8 → 16.0.9

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.9] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed OAuth login replacing all other active accounts for the same provider, allowing multiple OAuth accounts to coexist concurrently.
10
+ - Fixed legacy `api_key` credentials not being replaced/disabled atomically upon upgrading to OAuth login.
11
+ - Fixed a logic issue where AuthStorage lost session-to-credential stickiness upon CLI restarts, causing cold-starts for server-side prompt cache (KV cache) and wasting tokens.
12
+ - Fixed GitHub Copilot Responses requests rejecting image inputs that carry the `detail: "original"` hint with an HTTP 400 by degrading the hint to `"auto"` for hosts that do not support it; other hosts still preserve native-resolution frames (snapcompact). ([#2822](https://github.com/can1357/oh-my-pi/issues/2822))
13
+
5
14
  ## [16.0.8] - 2026-06-18
6
15
 
7
16
  ### Fixed
@@ -341,11 +341,12 @@ export declare function repairOrphanResponsesToolOutputs(input: ResponseInput):
341
341
  * {@link repairOrphanResponsesToolOutputs}.
342
342
  */
343
343
  export declare function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseInput;
344
- export declare function convertResponsesInputContent(content: string | Array<TextContent | ImageContent>, supportsImages: boolean): ResponseInputContent[] | undefined;
344
+ export declare function convertResponsesInputContent(content: string | Array<TextContent | ImageContent>, supportsImages: boolean, supportsImageDetailOriginal: boolean): ResponseInputContent[] | undefined;
345
345
  export interface BuildResponsesInputOptions<TApi extends Api> {
346
346
  model: Model<TApi>;
347
347
  context: Context;
348
348
  strictResponsesPairing: boolean;
349
+ supportsImageDetailOriginal: boolean;
349
350
  systemRole?: "system" | "developer";
350
351
  nativeHistory?: {
351
352
  replay: boolean;
@@ -357,7 +358,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
357
358
  }
358
359
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
359
360
  export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>): ResponseInput;
360
- export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>): void;
361
+ export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>): void;
361
362
  /**
362
363
  * Per-block accumulation helpers shared by the two Responses decode loops —
363
364
  * {@link processResponsesStream} (generic Responses) and the Codex stream
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.0.8",
4
+ "version": "16.0.9",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.0.8",
42
- "@oh-my-pi/pi-utils": "16.0.8",
41
+ "@oh-my-pi/pi-catalog": "16.0.9",
42
+ "@oh-my-pi/pi-utils": "16.0.9",
43
43
  "arktype": "^2.2.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "^4"
@@ -1352,6 +1352,19 @@ export class AuthStorage {
1352
1352
  const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
1353
1353
  sessionMap.set(sessionId, { type, index });
1354
1354
  this.#sessionLastCredential.set(provider, sessionMap);
1355
+
1356
+ try {
1357
+ const credentialId = this.#getStoredCredentials(provider)[index]?.id;
1358
+ if (credentialId !== undefined) {
1359
+ const cacheKey = `session:sticky:${provider}:${sessionId}`;
1360
+ const cacheValue = JSON.stringify({ type, index, credentialId });
1361
+ // Expires in 30 days
1362
+ const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
1363
+ this.#store.setCache(cacheKey, cacheValue, expiresAtSec);
1364
+ }
1365
+ } catch (err) {
1366
+ logger.debug("Failed to write session sticky credential to persistent store cache", { err });
1367
+ }
1355
1368
  }
1356
1369
 
1357
1370
  /** Retrieves the last credential used by a session. */
@@ -1360,17 +1373,59 @@ export class AuthStorage {
1360
1373
  sessionId: string | undefined,
1361
1374
  ): { type: AuthCredential["type"]; index: number } | undefined {
1362
1375
  if (!sessionId) return undefined;
1363
- return this.#sessionLastCredential.get(provider)?.get(sessionId);
1376
+ let sessionMap = this.#sessionLastCredential.get(provider);
1377
+ if (sessionMap?.has(sessionId)) {
1378
+ return sessionMap.get(sessionId);
1379
+ }
1380
+ try {
1381
+ const cacheKey = `session:sticky:${provider}:${sessionId}`;
1382
+ const raw = this.#store.getCache(cacheKey);
1383
+ if (raw) {
1384
+ const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
1385
+
1386
+ if (val.credentialId !== undefined) {
1387
+ const stored = this.#getStoredCredentials(provider);
1388
+ const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
1389
+ if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
1390
+ this.#store.setCache(cacheKey, "", 0);
1391
+ return undefined;
1392
+ }
1393
+ val.index = actualIndex;
1394
+ } else {
1395
+ // Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
1396
+ this.#store.setCache(cacheKey, "", 0);
1397
+ return undefined;
1398
+ }
1399
+
1400
+ if (!sessionMap) {
1401
+ sessionMap = new Map();
1402
+ this.#sessionLastCredential.set(provider, sessionMap);
1403
+ }
1404
+ const sessionVal = { type: val.type, index: val.index };
1405
+ sessionMap.set(sessionId, sessionVal);
1406
+ return sessionVal;
1407
+ }
1408
+ } catch (err) {
1409
+ logger.debug("Failed to read session sticky credential from persistent store cache", { err });
1410
+ }
1411
+ return undefined;
1364
1412
  }
1365
1413
 
1366
1414
  /** Clears the last credential used by a session for a provider. */
1367
1415
  #clearSessionCredential(provider: string, sessionId: string | undefined): void {
1368
1416
  if (!sessionId) return;
1369
1417
  const sessionMap = this.#sessionLastCredential.get(provider);
1370
- if (!sessionMap) return;
1371
- sessionMap.delete(sessionId);
1372
- if (sessionMap.size === 0) {
1373
- this.#sessionLastCredential.delete(provider);
1418
+ if (sessionMap) {
1419
+ sessionMap.delete(sessionId);
1420
+ if (sessionMap.size === 0) {
1421
+ this.#sessionLastCredential.delete(provider);
1422
+ }
1423
+ }
1424
+ try {
1425
+ const cacheKey = `session:sticky:${provider}:${sessionId}`;
1426
+ this.#store.setCache(cacheKey, "", 0);
1427
+ } catch (err) {
1428
+ logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
1374
1429
  }
1375
1430
  }
1376
1431
 
@@ -1549,6 +1604,17 @@ export class AuthStorage {
1549
1604
  return rows;
1550
1605
  }
1551
1606
 
1607
+ async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
1608
+ const stored = this.#store.upsertAuthCredentialRemote
1609
+ ? await this.#store.upsertAuthCredentialRemote(provider, credential)
1610
+ : this.#store.upsertAuthCredentialForProvider(provider, credential);
1611
+ this.#setStoredCredentials(
1612
+ provider,
1613
+ stored.map(entry => ({ id: entry.id, credential: entry.credential })),
1614
+ );
1615
+ this.#resetProviderAssignments(provider);
1616
+ }
1617
+
1552
1618
  /**
1553
1619
  * Remove credential for a provider.
1554
1620
  */
@@ -1786,10 +1852,10 @@ export class AuthStorage {
1786
1852
  return;
1787
1853
  }
1788
1854
  const newCredential: OAuthCredential = { type: "oauth", ...result };
1789
- // Use set() instead of #upsertOAuthCredential to replace ALL existing credentials
1790
- // (including legacy api_key rows from older versions) with the new OAuth credential.
1791
- // This ensures getApiKey() doesn't match an old api_key row before the new OAuth row.
1792
- await this.set(def.storeCredentialsAs ?? provider, newCredential);
1855
+ // Use #upsertOAuthCredential to upsert the new credential.
1856
+ // Any legacy api_key rows from older versions will be cleaned up so they do not
1857
+ // shadow the new OAuth row, while preserving other active OAuth credentials.
1858
+ await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
1793
1859
  }
1794
1860
 
1795
1861
  /**
@@ -4916,6 +4982,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4916
4982
  identityKey: resolveRowCredentialIdentityKey(providerName, row),
4917
4983
  }));
4918
4984
 
4985
+ if (item.type === "oauth") {
4986
+ for (const row of existing) {
4987
+ if (row.credential && row.credential.type === "api_key") {
4988
+ this.#deleteStmt.run("replaced by oauth login", row.id);
4989
+ }
4990
+ }
4991
+ }
4992
+
4919
4993
  let targetId: number | null = null;
4920
4994
  for (const row of existing) {
4921
4995
  if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
@@ -297,6 +297,7 @@ function buildParams(
297
297
  model,
298
298
  context,
299
299
  strictResponsesPairing: true,
300
+ supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
300
301
  systemRole,
301
302
  includeThinkingSignatures: true,
302
303
  developerStringContent: true,
@@ -3253,7 +3253,15 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
3253
3253
  }
3254
3254
 
3255
3255
  if (msg.role === "toolResult") {
3256
- appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
3256
+ appendResponsesToolResultMessages(
3257
+ messages,
3258
+ msg,
3259
+ model,
3260
+ false,
3261
+ model.compat.supportsImageDetailOriginal,
3262
+ knownCallIds,
3263
+ customCallIds,
3264
+ );
3257
3265
  }
3258
3266
 
3259
3267
  msgIndex += 1;
@@ -3271,7 +3279,10 @@ function normalizeInputMessageContent(
3271
3279
  return [{ type: "input_text", text: content.toWellFormed() }];
3272
3280
  }
3273
3281
 
3274
- return convertResponsesInputContent(content, model.input.includes("image")) ?? [];
3282
+ return (
3283
+ convertResponsesInputContent(content, model.input.includes("image"), model.compat.supportsImageDetailOriginal) ??
3284
+ []
3285
+ );
3275
3286
  }
3276
3287
 
3277
3288
  /** @internal Exported for tests. */
@@ -687,6 +687,7 @@ export function buildParams(
687
687
  model,
688
688
  context,
689
689
  strictResponsesPairing,
690
+ supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
690
691
  nativeHistory: {
691
692
  replay: shouldReplayNativeHistory,
692
693
  filterReasoning: policy.reasoning.filterReasoningHistory,
@@ -1178,9 +1178,24 @@ export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseIn
1178
1178
  return repaired;
1179
1179
  }
1180
1180
 
1181
+ /**
1182
+ * Some Responses backends (notably GitHub Copilot) reject the OpenAI image
1183
+ * `detail: "original"` value with a 400. When the model does not advertise
1184
+ * support for it, degrade `"original"` to `"auto"` so the request still goes
1185
+ * through with the closest valid fidelity instead of failing outright. See #2822.
1186
+ */
1187
+ function clampResponsesImageDetail(
1188
+ detail: ImageContent["detail"],
1189
+ supportsImageDetailOriginal: boolean,
1190
+ ): ResponseInputImage["detail"] {
1191
+ const resolved = detail ?? "auto";
1192
+ return resolved === "original" && !supportsImageDetailOriginal ? "auto" : resolved;
1193
+ }
1194
+
1181
1195
  export function convertResponsesInputContent(
1182
1196
  content: string | Array<TextContent | ImageContent>,
1183
1197
  supportsImages: boolean,
1198
+ supportsImageDetailOriginal: boolean,
1184
1199
  ): ResponseInputContent[] | undefined {
1185
1200
  if (typeof content === "string") {
1186
1201
  if (content.trim().length === 0) return undefined;
@@ -1200,7 +1215,7 @@ export function convertResponsesInputContent(
1200
1215
  for (const item of imageBlocks) {
1201
1216
  normalizedContent.push({
1202
1217
  type: "input_image",
1203
- detail: item.detail ?? "auto",
1218
+ detail: clampResponsesImageDetail(item.detail, supportsImageDetailOriginal),
1204
1219
  image_url: `data:${item.mimeType};base64,${item.data}`,
1205
1220
  } satisfies ResponseInputImage);
1206
1221
  }
@@ -1217,6 +1232,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
1217
1232
  model: Model<TApi>;
1218
1233
  context: Context;
1219
1234
  strictResponsesPairing: boolean;
1235
+ supportsImageDetailOriginal: boolean;
1220
1236
  systemRole?: "system" | "developer";
1221
1237
  nativeHistory?: {
1222
1238
  replay: boolean;
@@ -1267,7 +1283,11 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1267
1283
  msgIndex++;
1268
1284
  continue;
1269
1285
  }
1270
- const content = convertResponsesInputContent(msg.content, options.model.input.includes("image"));
1286
+ const content = convertResponsesInputContent(
1287
+ msg.content,
1288
+ options.model.input.includes("image"),
1289
+ options.supportsImageDetailOriginal,
1290
+ );
1271
1291
  if (!content) continue;
1272
1292
  messages.push({
1273
1293
  role: "user",
@@ -1318,6 +1338,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1318
1338
  msg,
1319
1339
  options.model,
1320
1340
  options.strictResponsesPairing,
1341
+ options.supportsImageDetailOriginal,
1321
1342
  knownCallIds,
1322
1343
  customCallIds,
1323
1344
  );
@@ -1419,6 +1440,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1419
1440
  toolResult: ToolResultMessage,
1420
1441
  model: Model<TApi>,
1421
1442
  strictResponsesPairing: boolean,
1443
+ supportsImageDetailOriginal: boolean,
1422
1444
  knownCallIds: ReadonlySet<string>,
1423
1445
  customCallIds?: ReadonlySet<string>,
1424
1446
  ): void {
@@ -1475,7 +1497,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1475
1497
  if (block.type === "image") {
1476
1498
  contentParts.push({
1477
1499
  type: "input_image",
1478
- detail: block.detail ?? "auto",
1500
+ detail: clampResponsesImageDetail(block.detail, supportsImageDetailOriginal),
1479
1501
  image_url: `data:${block.mimeType};base64,${block.data}`,
1480
1502
  } satisfies ResponseInputImage);
1481
1503
  }