@oh-my-pi/pi-ai 18.11.0 → 18.11.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/images/shared.d.ts +26 -2
  3. package/dist/types/judgment/native.d.ts +16 -1
  4. package/dist/types/judgment/text.d.ts +11 -5
  5. package/dist/types/judgment/types.d.ts +5 -1
  6. package/dist/types/judgment/wire.d.ts +18 -0
  7. package/dist/types/providers/anthropic.d.ts +2 -0
  8. package/dist/types/providers/cursor.d.ts +7 -0
  9. package/dist/types/providers/systemone-server.d.ts +4 -3
  10. package/dist/types/providers/transform-messages.d.ts +8 -0
  11. package/dist/types/types.d.ts +6 -0
  12. package/package.json +6 -6
  13. package/src/auth-broker/remote-store.ts +31 -6
  14. package/src/auth-gateway/routes/images.ts +2 -0
  15. package/src/auth-gateway/routes/systemone.ts +17 -2
  16. package/src/error/flags.ts +2 -2
  17. package/src/images/codex-format-directive.md +3 -0
  18. package/src/images/google-antigravity.ts +2 -1
  19. package/src/images/google-generative-ai.ts +4 -2
  20. package/src/images/openai-hosted.ts +25 -2
  21. package/src/images/openai-images.ts +2 -0
  22. package/src/images/shared.ts +77 -3
  23. package/src/judgment/chat.ts +17 -1
  24. package/src/judgment/native.ts +79 -6
  25. package/src/judgment/openai-decisions.ts +27 -6
  26. package/src/judgment/systemone.ts +17 -5
  27. package/src/judgment/text.ts +20 -7
  28. package/src/judgment/types.ts +5 -1
  29. package/src/judgment/wire.ts +19 -0
  30. package/src/providers/anthropic.ts +24 -4
  31. package/src/providers/cursor.ts +49 -9
  32. package/src/providers/images-server.ts +6 -1
  33. package/src/providers/openai-completions.ts +19 -4
  34. package/src/providers/systemone-server.ts +34 -10
  35. package/src/providers/transform-messages.ts +16 -1
  36. package/src/types.ts +6 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.11.1] - 2026-10-15
6
+
7
+ ### Added
8
+
9
+ - Added image support for native OpenAI Decisions and Workers AI judgments, prompted vision chat, and auth-gateway System One requests; images exceeding a judgment API's documented caps (e.g. Clef's 4 images, 4 MiB each) are rejected locally ([#15471](https://github.com/can1357/oh-my-pi/pull/15471) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)).
10
+ - `isSurvivingAssistant` in `providers/transform-messages`: whether an assistant turn reaches a replay, malformed tool calls dropped first, so callers outside `transformMessages` pick the same latest turn ([#15791](https://github.com/can1357/oh-my-pi/pull/15791) by [@will-bogusz](https://github.com/will-bogusz))
11
+
12
+ ### Fixed
13
+
14
+ - Fixed Codex-hosted image generation ignoring the requested `aspect_ratio`/`image_size`, and `3:2`/`2:3` requests sending no size to OpenAI image models ([#15601](https://github.com/can1357/oh-my-pi/pull/15601) by [@chulmin-dev](https://github.com/chulmin-dev))
15
+ - Fixed Claude subagents failing to return structured results when `yield` arguments arrived as invalid JSON ([#15605](https://github.com/can1357/oh-my-pi/pull/15605) by [@farnoy](https://github.com/farnoy))
16
+ - Fixed turns stopping instead of retrying when a gateway reports its upstream WebSocket closed before `response.completed` ([#15628](https://github.com/can1357/oh-my-pi/pull/15628) by [@nqrwhal](https://github.com/nqrwhal)).
17
+ - Fixed Claude on Google Vertex ignoring `cacheRetention: "long"`: prompt caches now get the 1h TTL instead of expiring after 5 minutes, and the `extended-cache-ttl` beta (which Vertex rejects) is never sent there ([#15505](https://github.com/can1357/oh-my-pi/pull/15505) by [@hpost](https://github.com/hpost))
18
+ - Fixed Gemini tool continuations failing on OpenAI-compatible gateways that return `reasoning_signature`, including restored sessions ([#15678](https://github.com/can1357/oh-my-pi/pull/15678) by [@Zzz141592653](https://github.com/Zzz141592653))
19
+ - Fixed Cursor sessions failing every rebuilt request with `PROVIDER_ERROR` after the server re-sent a tool call: the call is recorded once, and histories that already repeat it replay it once with its latest result ([#15749](https://github.com/can1357/oh-my-pi/issues/15749))
20
+ - Fixed Cursor turns cut off mid-stream failing every retry with `PROVIDER_ERROR` until you sent a new message; the retry now starts a fresh Cursor conversation ([#15830](https://github.com/can1357/oh-my-pi/issues/15830))
21
+ - Fixed malformed image-generation inputs being forwarded to providers or reported as credential/upstream errors ([#15765](https://github.com/can1357/oh-my-pi/pull/15765) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)).
22
+ - Fixed broker-backed auth-gateway processes retaining a cache entry for every distinct client session indefinitely under sustained traffic ([#15841](https://github.com/can1357/oh-my-pi/issues/15841)).
23
+ - Fixed turns ending on a pinned error when a provider reports "temporarily unavailable" or finishes with an `unknown`, `other` or `unspecified` finish reason: these now retry automatically ([#15424](https://github.com/can1357/oh-my-pi/pull/15424) by [@lockwo](https://github.com/lockwo))
24
+
5
25
  ## [18.11.0] - 2026-10-14
6
26
 
7
27
  ### Fixed
@@ -1,7 +1,8 @@
1
1
  import type { FetchImpl, Model, Usage } from "@oh-my-pi/pi-catalog/types";
2
2
  import type { ApiKey } from "../auth-retry.js";
3
3
  import * as AIError from "../error/index.js";
4
- import type { GeneratedImage } from "./types.js";
4
+ import type { ImageContent } from "../types.js";
5
+ import type { GeneratedImage, ImageInput } from "./types.js";
5
6
  export declare class ImageApiError extends AIError.ProviderHttpError {
6
7
  readonly name = "ImageApiError";
7
8
  }
@@ -38,6 +39,29 @@ export declare function decodeImageResponse(value: unknown, fetch: FetchImpl, si
38
39
  * multi-MB payloads are never fully decoded just to be labelled.
39
40
  */
40
41
  export declare function sniffBase64ImageMimeType(base64: string): string | undefined;
41
- export declare function toDataUrl(image: GeneratedImage): string;
42
+ /**
43
+ * Validate provider-bound image input without changing it. Accepts non-empty standard base64
44
+ * with canonical trailing bits and optional padding, and case-insensitive image MIME syntax
45
+ * (RFC 6838 restricted names). Does not sniff the MIME type, decode image pixels, or enforce
46
+ * provider-specific format support; data-URL transports additionally go through `toDataUrl`.
47
+ *
48
+ * @throws {AIError.ValidationError} If the base64 data or image MIME type is invalid.
49
+ */
50
+ export declare function validateImageInput(image: ImageInput): void;
51
+ /**
52
+ * Encode validated image input as a base64 data URL.
53
+ *
54
+ * @throws {AIError.ValidationError} If the input is invalid or its MIME type cannot survive a data URL.
55
+ */
56
+ export declare function toDataUrl(image: ImageInput): string;
57
+ /**
58
+ * Whether a MIME type survives a data URL intact: a raw `#` starts a URL fragment and
59
+ * data-URL consumers leave a `%23` escape undecoded, so no data-URL transport preserves it.
60
+ */
61
+ export declare function isDataUrlMimeType(mimeType: string): boolean;
62
+ /** Whether a value is an embedded image with valid base64 data, MIME type, and detail. */
63
+ export declare function isEmbeddedImageContent(image: unknown): image is ImageContent;
64
+ /** Convert one validated embedded image to a lowercase-MIME inline data URL. */
65
+ export declare function embeddedImageDataUrl(image: unknown): string;
42
66
  export declare function resolveOpenAIImageSize(aspectRatio?: string, imageSize?: string): string | undefined;
43
67
  export {};
@@ -1,7 +1,7 @@
1
1
  import type { Api, FetchImpl } from "@oh-my-pi/pi-catalog/types";
2
2
  import { type ApiKey } from "../auth-retry.js";
3
3
  import * as AIError from "../error/index.js";
4
- import type { Model, Usage } from "../types.js";
4
+ import type { ImageContent, Model, Usage } from "../types.js";
5
5
  import { type Judge, type JudgeOptions, type JudgmentRequest, type JudgmentResult, type Questions } from "./types.js";
6
6
  import type { JudgmentWire } from "./wire.js";
7
7
  export declare const TYPESAFE_PROVIDER = "typesafe";
@@ -16,6 +16,14 @@ declare const JUDGMENT_WIRES: {
16
16
  export type JudgmentApi = keyof typeof JUDGMENT_WIRES;
17
17
  /** Whether a catalog API answers judgments natively. */
18
18
  export declare function isJudgmentApi(api: Api): api is JudgmentApi;
19
+ /** Whether both the native protocol and catalog model support image input. */
20
+ export declare function nativeJudgmentSupportsImages(model: Model): boolean;
21
+ /**
22
+ * Reject `images` that `model`'s native judgment API cannot take — no image support, or a
23
+ * documented per-dialect cap exceeded — before any credential or upstream work.
24
+ * @throws {AIError.ValidationError} naming the reason.
25
+ */
26
+ export declare function checkNativeJudgmentImages(model: Model, images: readonly ImageContent[]): void;
19
27
  /** `TYPESAFE_BASE_URL` when set, else the public API root; trailing slashes stripped. */
20
28
  export declare function typesafeBaseUrl(): string;
21
29
  /** `TYPESAFE_DEFAULT_MODEL` when set, else {@link TYPESAFE_DEFAULT_MODEL}. */
@@ -43,6 +51,11 @@ export interface NativeJudgeOptions extends NativeJudgeConnection {
43
51
  * requests are split. Unset: no cap.
44
52
  */
45
53
  maxQuestions?: number;
54
+ /**
55
+ * Whether the catalogued model accepts images. `false` disables image input;
56
+ * a `true` value cannot enable a wire dialect that does not support images.
57
+ */
58
+ supportsImages?: boolean;
46
59
  }
47
60
  /** Per-call options for {@link NativeJudge.judge}. */
48
61
  export interface NativeJudgeCallOptions extends JudgeOptions {
@@ -60,6 +73,8 @@ export declare class JudgmentApiError extends AIError.ProviderHttpError {
60
73
  export declare class NativeJudge implements Judge {
61
74
  #private;
62
75
  readonly label: string;
76
+ /** True when the selected native wire and model both accept image inputs. */
77
+ readonly supportsImages?: boolean;
63
78
  readonly api: JudgmentApi;
64
79
  readonly provider: string;
65
80
  /** Wire model id sent with every request. */
@@ -1,8 +1,10 @@
1
- import type { Usage } from "../types.js";
1
+ import type { ImageContent, Usage } from "../types.js";
2
2
  import { type Answer, type Judge, type JudgeOptions, type JudgmentRequest, type JudgmentResult, type JudgmentState, type Question, type Questions } from "./types.js";
3
3
  export interface TextPrompt {
4
4
  system: string;
5
5
  user: string;
6
+ /** Images are kept as distinct content blocks, never rendered into JSON text. */
7
+ images?: readonly ImageContent[];
6
8
  /** Format-correction attempt; chat backends may enforce tool suppression on the wire. */
7
9
  retry?: boolean;
8
10
  }
@@ -16,6 +18,8 @@ export interface TextBackend {
16
18
  readonly api: string;
17
19
  readonly provider: string;
18
20
  readonly model: string;
21
+ /** Whether this backend can send image content on the wire. */
22
+ readonly supportsImages?: boolean;
19
23
  /** State guard for agent-tuned chat models; on-device classifier workers may disable it. */
20
24
  readonly guardState?: boolean;
21
25
  /** Format-correction retries after a completion cannot be parsed. */
@@ -26,10 +30,11 @@ export interface TextBackend {
26
30
  export declare function renderJudgmentState(state: JudgmentState): string;
27
31
  /**
28
32
  * Render a request: the system prompt carries the preamble and question
29
- * definitions (constant across states, so prompt caches hit); the user message
30
- * carries XML-field state followed by the answer-format cue. Small models act
31
- * on a bare request (answer it, emit tool calls) instead of classifying it —
32
- * explicit tags and the trailing cue keep them on task.
33
+ * definitions (constant across states, so prompt caches hit); the user text
34
+ * carries XML-field state followed by the answer-format cue. Images remain
35
+ * separate content blocks. Small models act on a bare request (answer it,
36
+ * emit tool calls) instead of classifying it — explicit tags and the trailing
37
+ * cue keep them on task.
33
38
  */
34
39
  export declare function renderJudgmentPrompt(request: JudgmentRequest, options?: {
35
40
  guardState?: boolean;
@@ -53,6 +58,7 @@ export declare function splitAnswerLines(text: string, ids: readonly string[]):
53
58
  export declare function parseAnswer(id: string, question: Question, reply: string): Answer;
54
59
  export declare class TextJudge implements Judge {
55
60
  #private;
61
+ readonly supportsImages?: boolean;
56
62
  readonly label: string;
57
63
  constructor(backend: TextBackend);
58
64
  judge<Q extends Questions>(request: JudgmentRequest<Q>, options?: JudgeOptions): Promise<JudgmentResult<Q>>;
@@ -11,7 +11,7 @@
11
11
  * bridge ({@link TextJudge}) renders the same questions into keyword prompts
12
12
  * for an ordinary chat model.
13
13
  */
14
- import type { Usage } from "../types.js";
14
+ import type { ImageContent, Usage } from "../types.js";
15
15
  /** JSON-compatible value; readonly containers are accepted so `as const` state passes through. */
16
16
  export type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
17
17
  readonly [key: string]: JsonValue;
@@ -73,6 +73,8 @@ export type AnswerFor<Q extends Question> = Q extends ChoiceQuestion<infer L> ?
73
73
  export interface JudgmentRequest<Q extends Questions = Questions> {
74
74
  state: JudgmentState;
75
75
  questions: Q;
76
+ /** Images evaluated alongside the state; never part of the JSON state itself. */
77
+ images?: readonly ImageContent[];
76
78
  }
77
79
  export interface JudgmentResult<Q extends Questions = Questions> {
78
80
  /** Transport that produced the answers (a judgment api such as `typesafe`, or the chat model's api). */
@@ -89,6 +91,8 @@ export interface JudgeOptions {
89
91
  }
90
92
  /** Answers typed questions about a state. Implementations are stateless and safe to share. */
91
93
  export interface Judge {
94
+ /** Whether this backend can consume separate image content blocks. */
95
+ readonly supportsImages?: boolean;
92
96
  /** Backend description for logs, e.g. `typesafe/jev-latest`. */
93
97
  readonly label: string;
94
98
  judge<Q extends Questions>(request: JudgmentRequest<Q>, options?: JudgeOptions): Promise<JudgmentResult<Q>>;
@@ -20,6 +20,22 @@ export interface DecodedJudgment {
20
20
  refused: readonly string[];
21
21
  usage: Usage;
22
22
  }
23
+ /**
24
+ * Image input one judgment dialect accepts. Every cap is optional; {@link NativeJudge}
25
+ * rejects a request exceeding one locally instead of spending an upstream call on it.
26
+ */
27
+ export interface JudgmentImageInput {
28
+ /** Accepted lowercase MIME types; unset accepts any image type. */
29
+ mimeTypes?: readonly string[];
30
+ /** Images per request. */
31
+ maxCount?: number;
32
+ /** Decoded bytes per image. */
33
+ maxBytes?: number;
34
+ /** Decoded bytes across all images. */
35
+ maxTotalBytes?: number;
36
+ /** Pixels per image, when the image header reports dimensions. */
37
+ maxPixels?: number;
38
+ }
23
39
  /** How one judgment API addresses, encodes, and decodes a request. */
24
40
  export interface JudgmentWire {
25
41
  /**
@@ -29,6 +45,8 @@ export interface JudgmentWire {
29
45
  target(baseUrl: string, model: string, key: string): JudgmentTarget;
30
46
  /** Request body for `model`. */
31
47
  encode(request: JudgmentRequest, model: string): unknown;
48
+ /** Image input this dialect accepts; absent means text-only. */
49
+ images?: JudgmentImageInput;
32
50
  /** The response's answers and usage; `undefined` when the payload is not this API's response shape. */
33
51
  decode(payload: unknown): DecodedJudgment | undefined;
34
52
  }
@@ -133,6 +133,8 @@ export type AnthropicClientOptionsArgs = {
133
133
  dynamicHeaders?: Record<string, string>;
134
134
  isOAuth?: boolean;
135
135
  hasTools?: boolean;
136
+ /** Whether a tool in ANTHROPIC_BUFFERED_INPUT_TOOLS is present; suppresses the request-wide fine-grained streaming beta. */
137
+ hasBufferedInputTools?: boolean;
136
138
  thinkingEnabled?: boolean;
137
139
  thinkingDisplay?: AnthropicThinkingDisplay;
138
140
  disableStrictTools?: boolean;
@@ -218,6 +218,13 @@ export declare function mergeCursorMcpToolCallArgs(streamed: Record<string, unkn
218
218
  * call handed to an external executor is only a handoff while the marker is
219
219
  * absent, and the consumer that runs it needs a runnable block.
220
220
  *
221
+ * A frame whose `toolCallId` already has a block in `output` is the server
222
+ * re-dispatching that call (a new `execId`, e.g. a `read` re-sent once a
223
+ * parallel shell created the file). It adds no block: a second block with the
224
+ * same id makes every rebuilt history replay the call twice, which Cursor
225
+ * rejects (issue #15749). The re-run's result supersedes the first one in the
226
+ * host's result buffer.
227
+ *
221
228
  * Exported for tests to exercise ordering with adjacent text/thinking blocks.
222
229
  */
223
230
  export declare function synthesizeCursorExecToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, toolCallId: string, toolName: string, args: Record<string, unknown>, { executed }?: {
@@ -14,10 +14,11 @@ export interface SystemOneResponseBody {
14
14
  };
15
15
  }
16
16
  /**
17
- * Validate the routing-relevant parts of a System One request body.
17
+ * Validate the routing-relevant parts of a System One request body, including
18
+ * optional embedded base64 images.
18
19
  *
19
- * @throws {AIError.ValidationError} naming the offending field when `model`,
20
- * `state`, or `questions` is missing or malformed.
20
+ * @throws {AIError.ValidationError} when required routing fields are missing
21
+ * or malformed, or a supplied image is malformed.
21
22
  */
22
23
  export declare function parseRequest(body: unknown): SystemOneParsedRequest;
23
24
  /** Re-encode a judgment result in TypeSafe's response shape. */
@@ -36,6 +36,14 @@ export declare function isMalformedToolCallName(name: unknown): boolean;
36
36
  * off by `length` / `error` / `aborted` that carries no text and no tool call.
37
37
  */
38
38
  export declare function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean;
39
+ /**
40
+ * Whether `msg` reaches a replay as an assistant turn, so it can be the latest
41
+ * surviving one: {@link sanitizeMalformedToolCalls} drops its malformed tool
42
+ * calls, then {@link shouldDropTruncatedThinkingOnlyAssistant} a truncated
43
+ * turn left with no text and no tool call. On an already sanitized list this
44
+ * is `!shouldDropTruncatedThinkingOnlyAssistant(msg)`.
45
+ */
46
+ export declare function isSurvivingAssistant(msg: AssistantMessage): boolean;
39
47
  /**
40
48
  * Normalize tool call ID for cross-provider compatibility.
41
49
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -982,6 +982,12 @@ export type AssistantRetryRecovery = {
982
982
  export interface ContextSnapshot {
983
983
  promptTokens: number;
984
984
  nonMessageTokens: number;
985
+ /**
986
+ * Estimated tool-schema tokens included in `nonMessageTokens` (0 when the
987
+ * model defers schemas server-side). Lets a later model whose schema policy
988
+ * differs rebase the provider anchor instead of inheriting its schemas.
989
+ */
990
+ toolSchemaTokens?: number;
985
991
  /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
986
992
  historyRewriteTokensRemoved?: number;
987
993
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.11.0",
3
+ "version": "18.11.1",
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.11.0",
159
- "@oh-my-pi/pi-catalog": "18.11.0",
160
- "@oh-my-pi/pi-natives": "18.11.0",
161
- "@oh-my-pi/pi-utils": "18.11.0",
162
- "@oh-my-pi/pi-wire": "18.11.0"
158
+ "@oh-my-pi/omptype": "18.11.1",
159
+ "@oh-my-pi/pi-catalog": "18.11.1",
160
+ "@oh-my-pi/pi-natives": "18.11.1",
161
+ "@oh-my-pi/pi-utils": "18.11.1",
162
+ "@oh-my-pi/pi-wire": "18.11.1"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -3,9 +3,9 @@
3
3
  * snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
4
4
  * `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
5
5
  *
6
- * Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
7
- * usage reports cache TTL is 5 minutes per credential, so durability across
8
- * runs isn't required.
6
+ * Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral.
7
+ * The bounded hot set preserves recently used session pins without keeping
8
+ * every gateway client's 30-day sticky row resident.
9
9
  */
10
10
  import * as os from "node:os";
11
11
  import { getAppName, getInstallId, logger, postmortem } from "@oh-my-pi/pi-utils";
@@ -36,6 +36,11 @@ import type {
36
36
  SnapshotStreamEvent,
37
37
  } from "./types";
38
38
 
39
+ // Bound both the number of distinct session pins and the size of caller-supplied
40
+ // keys/values; an evicted session falls back to normal credential selection.
41
+ const REMOTE_CACHE_MAX_ENTRIES = 4096;
42
+ const REMOTE_CACHE_MAX_CHARACTERS = 4 * 1024 * 1024;
43
+
39
44
  /**
40
45
  * Per-provider OAuth identities visible to this trusted broker client.
41
46
  * Missing providers are unrestricted; an empty set excludes that provider's
@@ -289,6 +294,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
289
294
  /** Present while the background loop is parked; resolved by `#noteActivity` or `close()`. */
290
295
  #activityWakeup: PromiseWithResolvers<void> | null = null;
291
296
  #cache: Map<string, CacheEntry> = new Map();
297
+ #cacheCharacters = 0;
292
298
  #usageCache?: UsageCacheEntry;
293
299
  #usageInflight?: Promise<UsageReport[] | null>;
294
300
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
@@ -1177,28 +1183,46 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1177
1183
  const entry = this.#cache.get(key);
1178
1184
  if (!entry) return null;
1179
1185
  if (entry.expiresAtSec * 1000 <= Date.now()) {
1180
- this.#cache.delete(key);
1186
+ this.#deleteCache(key);
1181
1187
  return null;
1182
1188
  }
1189
+ this.#cache.delete(key);
1190
+ this.#cache.set(key, entry);
1183
1191
  return entry.value;
1184
1192
  }
1185
1193
 
1186
1194
  setCache(key: string, value: string, expiresAtSec: number): void {
1187
1195
  this.#noteActivity();
1196
+ this.#deleteCache(key);
1197
+ const size = key.length + value.length;
1198
+ if (size > REMOTE_CACHE_MAX_CHARACTERS) return;
1188
1199
  this.#cache.set(key, { value, expiresAtSec });
1200
+ this.#cacheCharacters += size;
1201
+ while (this.#cache.size > REMOTE_CACHE_MAX_ENTRIES || this.#cacheCharacters > REMOTE_CACHE_MAX_CHARACTERS) {
1202
+ const oldest = this.#cache.keys().next().value;
1203
+ if (oldest === undefined) break;
1204
+ this.#deleteCache(oldest);
1205
+ }
1206
+ }
1207
+
1208
+ #deleteCache(key: string): void {
1209
+ const entry = this.#cache.get(key);
1210
+ if (!entry) return;
1211
+ this.#cacheCharacters -= key.length + entry.value.length;
1212
+ this.#cache.delete(key);
1189
1213
  }
1190
1214
 
1191
1215
  /** Drop all cache rows whose keys start with the supplied prefix. */
1192
1216
  deleteCachePrefix(prefix: string): void {
1193
1217
  for (const key of this.#cache.keys()) {
1194
- if (key.startsWith(prefix)) this.#cache.delete(key);
1218
+ if (key.startsWith(prefix)) this.#deleteCache(key);
1195
1219
  }
1196
1220
  }
1197
1221
 
1198
1222
  cleanExpiredCache(): void {
1199
1223
  const nowSec = Math.floor(Date.now() / 1000);
1200
1224
  for (const [key, entry] of this.#cache) {
1201
- if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
1225
+ if (entry.expiresAtSec <= nowSec) this.#deleteCache(key);
1202
1226
  }
1203
1227
  }
1204
1228
 
@@ -1572,6 +1596,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1572
1596
  // process exiting right after close() still sends it; failures are dropped.
1573
1597
  void this.#flushObservedUsage().finally(() => this.#cancelObservedUsageExitFlush?.());
1574
1598
  this.#cache.clear();
1599
+ this.#cacheCharacters = 0;
1575
1600
  this.#usageOverlays.clear();
1576
1601
  }
1577
1602
  }
@@ -2,6 +2,7 @@ import { calculateCost } from "@oh-my-pi/pi-catalog/models";
2
2
  import { logger } from "@oh-my-pi/pi-utils";
3
3
  import { classifyGatewayError } from "../../error/gateway";
4
4
  import { generateImage } from "../../images";
5
+ import { validateImageInput } from "../../images/shared";
5
6
  import * as imagesServer from "../../providers/images-server";
6
7
  import { deterministicUuid } from "../../utils/deterministic-id";
7
8
  import {
@@ -49,6 +50,7 @@ async function handleImages(
49
50
  let parsed: imagesServer.ImagesParsedRequest;
50
51
  try {
51
52
  parsed = await imagesServer.parseRequest(body, kind);
53
+ for (const image of parsed.request.inputImages ?? []) validateImageInput(image);
52
54
  } catch (error) {
53
55
  const message = error instanceof Error ? error.message : String(error);
54
56
  return imagesServer.formatError(400, "invalid_request_error", message);
@@ -13,8 +13,9 @@
13
13
  */
14
14
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
15
15
  import { logger } from "@oh-my-pi/pi-utils";
16
+ import * as AIError from "../../error";
16
17
  import { classifyGatewayError } from "../../error/gateway";
17
- import { createNativeJudge, isJudgmentApi } from "../../judgment/native";
18
+ import { checkNativeJudgmentImages, createNativeJudge, isJudgmentApi } from "../../judgment/native";
18
19
  import * as systemOne from "../../providers/systemone-server";
19
20
  import { deterministicUuid } from "../../utils/deterministic-id";
20
21
  import {
@@ -66,6 +67,16 @@ export async function handleSystemOne(
66
67
  `Model ${parsed.modelId} does not answer System One judgments`,
67
68
  );
68
69
  }
70
+ // Model capability and dialect caps are known locally: reject before credential lookup or refresh.
71
+ try {
72
+ checkNativeJudgmentImages(model, parsed.request.images ?? []);
73
+ } catch (error) {
74
+ return systemOne.formatError(
75
+ 422,
76
+ "invalid_request_error",
77
+ error instanceof Error ? error.message : String(error),
78
+ );
79
+ }
69
80
  const client = resolveClientIdentity(req.headers);
70
81
  // Judgments carry no conversation to derive a session from. One sticky
71
82
  // session per model keeps credential selection stable across calls and
@@ -117,7 +128,11 @@ export async function handleSystemOne(
117
128
  return json(200, body, gatewayResponseHeaders(model, { requestId, costUsd: result.usage.cost.total, startedAt }));
118
129
  } catch (error) {
119
130
  if (controller.signal.aborted) return aborted();
120
- const classified = classifyGatewayError(error);
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ const classified =
133
+ error instanceof AIError.ValidationError
134
+ ? { status: 422, type: "invalid_request_error", message }
135
+ : classifyGatewayError(error);
121
136
  logger.warn("auth-gateway judgment failed", { format: "systemone", error: classified.message, peer });
122
137
  return systemOne.formatError(classified.status, classified.type, classified.message);
123
138
  }
@@ -224,7 +224,7 @@ export function isCodexSteerRejection(message: { api?: Api; errorMessage?: strin
224
224
  * network connection", #15385).
225
225
  */
226
226
  export const TRANSIENT_TRANSPORT_PATTERN =
227
- /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|use of closed network connection|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
227
+ /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|temporar(?:y|ily)[ _-]?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|use of closed network connection|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
228
228
  const AUTH_FAILURE_PATTERN =
229
229
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
230
230
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -1100,7 +1100,7 @@ export function isTransientStreamParseError(error: unknown): boolean {
1100
1100
  * non-terminal status treat it as transient (#11805).
1101
1101
  */
1102
1102
  const STREAM_DROP_PATTERN =
1103
- /stream disconnected before completion|stream closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1103
+ /stream disconnected before completion|(?:stream|websocket) closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1104
1104
 
1105
1105
  /**
1106
1106
  * Transport stream-drop diagnostic (see {@link STREAM_DROP_PATTERN}). Unlike
@@ -0,0 +1,3 @@
1
+ {{{prompt}}}
2
+
3
+ Output format: {{shape}} image, {{#if pixels}}{{width}}x{{height}} pixels{{else}}{{width}}:{{height}} aspect ratio{{/if}}.
@@ -8,7 +8,7 @@ import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-header
8
8
  import { readSseJson } from "@oh-my-pi/pi-utils";
9
9
  import { withAuth } from "../auth-retry";
10
10
  import * as AIError from "../error";
11
- import { errorMessage, ImageApiError, usageFromWire } from "./shared";
11
+ import { errorMessage, ImageApiError, usageFromWire, validateImageInput } from "./shared";
12
12
  import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
13
13
 
14
14
  const IMAGE_SYSTEM_INSTRUCTION =
@@ -138,6 +138,7 @@ export async function generateAntigravityImage(
138
138
  request: ImageGenerationRequest,
139
139
  options: ImageGenerationOptions,
140
140
  ): Promise<ImageGenerationResult> {
141
+ for (const image of request.inputImages ?? []) validateImageInput(image);
141
142
  const fetchImpl = options.fetch ?? fetch;
142
143
  const response = await withAuth(
143
144
  options.apiKey,
@@ -1,7 +1,7 @@
1
1
  import type { Model } from "@oh-my-pi/pi-catalog/types";
2
2
  import { withAuth } from "../auth-retry";
3
3
  import * as AIError from "../error";
4
- import { errorMessage, ImageApiError, imageBaseUrl, modelHeaders, usageFromWire } from "./shared";
4
+ import { errorMessage, ImageApiError, imageBaseUrl, modelHeaders, usageFromWire, validateImageInput } from "./shared";
5
5
  import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
6
6
 
7
7
  interface GeminiPart {
@@ -20,7 +20,9 @@ export async function generateGoogleImage(
20
20
  options: ImageGenerationOptions,
21
21
  ): Promise<ImageGenerationResult> {
22
22
  const fetchImpl = options.fetch ?? fetch;
23
- const parts: Array<{ text?: string; inlineData?: GeneratedImage }> = (request.inputImages ?? []).map(image => ({
23
+ const inputImages = request.inputImages ?? [];
24
+ for (const image of inputImages) validateImageInput(image);
25
+ const parts: Array<{ text?: string; inlineData?: GeneratedImage }> = inputImages.map(image => ({
24
26
  inlineData: image,
25
27
  }));
26
28
  parts.push({ text: request.prompt });
@@ -7,7 +7,7 @@ import {
7
7
  OPENAI_HEADERS,
8
8
  URL_PATHS,
9
9
  } from "@oh-my-pi/pi-catalog/wire/codex";
10
- import { readSseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
10
+ import { prompt, readSseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
11
11
  import { withAuth } from "../auth-retry";
12
12
  import * as AIError from "../error";
13
13
  import { applySessionHeader } from "../providers/inference-headers";
@@ -20,6 +20,7 @@ import {
20
20
  toDataUrl,
21
21
  usageFromWire,
22
22
  } from "./shared";
23
+ import codexFormatDirective from "./codex-format-directive.md" with { type: "text" };
23
24
  import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
24
25
 
25
26
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
@@ -143,6 +144,27 @@ async function parseSse(response: Response, signal?: AbortSignal): Promise<Image
143
144
  return collectResponse(completed?.output?.length ? completed : { ...completed, output: fallbackOutput });
144
145
  }
145
146
 
147
+ /**
148
+ * The Codex backend runs its image tool with `size: "auto"`, ignoring the requested `size`, and
149
+ * shapes the canvas from the prompt alone. Spell the requested shape out so it is honored.
150
+ */
151
+ function withCodexFormatDirective(request: ImageGenerationRequest): string {
152
+ const dimensions = request.imageSize?.match(/^(\d+)x(\d+)$/);
153
+ const ratio = dimensions ? undefined : request.aspectRatio?.match(/^(\d+):(\d+)$/);
154
+ const match = dimensions ?? ratio;
155
+ if (!match) return request.prompt;
156
+ const width = Number(match[1]);
157
+ const height = Number(match[2]);
158
+ const shape = width === height ? "square" : width > height ? "landscape" : "portrait";
159
+ return prompt.render(codexFormatDirective, {
160
+ prompt: request.prompt,
161
+ shape,
162
+ width,
163
+ height,
164
+ pixels: dimensions !== undefined,
165
+ });
166
+ }
167
+
146
168
  export async function generateHostedImage(
147
169
  model: Model,
148
170
  request: ImageGenerationRequest,
@@ -154,7 +176,8 @@ export async function generateHostedImage(
154
176
  throw new AIError.ValidationError(`Hosted image carrier API ${carrier.api} is unsupported`);
155
177
  }
156
178
  const stream = carrier.api === "openai-codex-responses";
157
- const content: Array<Record<string, unknown>> = [{ type: "input_text", text: request.prompt }];
179
+ const inputText = stream ? withCodexFormatDirective(request) : request.prompt;
180
+ const content: Array<Record<string, unknown>> = [{ type: "input_text", text: inputText }];
158
181
  for (const image of request.inputImages ?? []) {
159
182
  content.push({ type: "input_image", detail: "auto", image_url: toDataUrl(image) });
160
183
  }
@@ -8,6 +8,7 @@ import {
8
8
  postMultipart,
9
9
  resolveOpenAIImageSize,
10
10
  toDataUrl,
11
+ validateImageInput,
11
12
  } from "./shared";
12
13
  import type { ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
13
14
 
@@ -85,6 +86,7 @@ export async function generateOpenAIImage(
85
86
  form.set("response_format", "b64_json");
86
87
  if (size) form.set("size", size);
87
88
  for (const image of inputImages) {
89
+ validateImageInput(image);
88
90
  form.append("image", new File([Buffer.from(image.data, "base64")], "image", { type: image.mimeType }));
89
91
  }
90
92
  response = await postMultipart({