@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.
- package/CHANGELOG.md +20 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/judgment/native.d.ts +16 -1
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +5 -1
- package/dist/types/judgment/wire.d.ts +18 -0
- package/dist/types/providers/anthropic.d.ts +2 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/systemone-server.d.ts +4 -3
- package/dist/types/providers/transform-messages.d.ts +8 -0
- package/dist/types/types.d.ts +6 -0
- package/package.json +6 -6
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/routes/images.ts +2 -0
- package/src/auth-gateway/routes/systemone.ts +17 -2
- package/src/error/flags.ts +2 -2
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +2 -0
- package/src/images/shared.ts +77 -3
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +79 -6
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +17 -5
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +19 -0
- package/src/providers/anthropic.ts +24 -4
- package/src/providers/cursor.ts +49 -9
- package/src/providers/images-server.ts +6 -1
- package/src/providers/openai-completions.ts +19 -4
- package/src/providers/systemone-server.ts +34 -10
- package/src/providers/transform-messages.ts +16 -1
- 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 {
|
|
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
|
-
|
|
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
|
|
30
|
-
* carries XML-field state followed by the answer-format cue.
|
|
31
|
-
* on a bare request (answer it,
|
|
32
|
-
* explicit tags and the trailing
|
|
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}
|
|
20
|
-
*
|
|
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 `|`.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.11.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.11.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.11.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.11.
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
|
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
|
}
|
package/src/error/flags.ts
CHANGED
|
@@ -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
|
|
@@ -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
|
|
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
|
|
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({
|