@linxiraos/pi-ai 1.2.0 → 1.3.0
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 +83 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +51 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- 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 +16 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +26 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/utils/dsml-leak.d.ts +4 -2
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +8 -8
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- 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 +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +182 -12
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +131 -10
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +17 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +15 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +95 -51
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +30 -14
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local transport diagnosis for a rejected `fetch`.
|
|
3
|
+
*
|
|
4
|
+
* Bun and Node describe the same outage with different shapes — Bun throws a
|
|
5
|
+
* `TypeError` carrying `code: "ETIMEOUT" | "FailedToOpenSocket" |
|
|
6
|
+
* "ConnectionRefused"`, undici nests an `ENOTFOUND`/`ECONNREFUSED` errno under
|
|
7
|
+
* `TypeError: fetch failed` (sometimes through an `AggregateError`), and
|
|
8
|
+
* `fetchWithRetry` may already have flattened the chain into a message-only
|
|
9
|
+
* `Error`. Both the code and the text are inspected so every one of those
|
|
10
|
+
* shapes lands in the right bucket.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Transport-level failure families that get their own user-visible wording. */
|
|
14
|
+
export type NetworkFailureKind = "offline" | "unreachable" | "refused" | "tls";
|
|
15
|
+
|
|
16
|
+
/** What {@link classifyNetworkCause} reports about a rejected `fetch`: the failure family plus the bits its sentence needs. */
|
|
17
|
+
export interface NetworkFailure {
|
|
18
|
+
kind: NetworkFailureKind;
|
|
19
|
+
/** Runtime error code when the chain exposed one (`ETIMEOUT`, `FailedToOpenSocket`, …). */
|
|
20
|
+
code: string | undefined;
|
|
21
|
+
/** Short parenthetical detail for the user-visible message. */
|
|
22
|
+
detail: string | undefined;
|
|
23
|
+
/** Host named by the error text, when it named one. */
|
|
24
|
+
host: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
28
|
+
const MAX_AGGREGATE_BRANCH = 4;
|
|
29
|
+
const MAX_LINKS = 16;
|
|
30
|
+
const MAX_DETAIL_LENGTH = 120;
|
|
31
|
+
const UNKNOWN_HOST = "the API host";
|
|
32
|
+
|
|
33
|
+
const OFFLINE_CODE_PATTERN = /^(?:ETIMEOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)$/i;
|
|
34
|
+
const OFFLINE_TEXT_PATTERN = /\b(?:ETIMEOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b/i;
|
|
35
|
+
const UNREACHABLE_CODE_PATTERN = /^(?:ENOTFOUND|FailedToOpenSocket)$/i;
|
|
36
|
+
const UNREACHABLE_TEXT_PATTERN = /\b(?:ENOTFOUND|FailedToOpenSocket|getaddrinfo)\b/i;
|
|
37
|
+
const REFUSED_CODE_PATTERN = /^(?:ECONNREFUSED|ConnectionRefused)$/i;
|
|
38
|
+
const REFUSED_TEXT_PATTERN = /\bECONNREFUSED\b|\bConnectionRefused\b|\bUnable to connect\b/i;
|
|
39
|
+
const TLS_CODE_PATTERN =
|
|
40
|
+
/^(?:CERT_|SELF_SIGNED|UNABLE_TO_GET_ISSUER_CERT|UNABLE_TO_VERIFY|DEPTH_ZERO_SELF_SIGNED_CERT|ERR_TLS_CERT)/i;
|
|
41
|
+
const TLS_TEXT_PATTERN =
|
|
42
|
+
/unable to verify|certificate verif(?:y|ication)|self[- ]signed certificate|\bCERT_[A-Z_]+\b|\bSELF_SIGNED[A-Z_]*\b|\bUNABLE_TO_GET_ISSUER_CERT[A-Z_]*\b|\bUNABLE_TO_VERIFY_LEAF_SIGNATURE\b/i;
|
|
43
|
+
const GETADDRINFO_HOST_PATTERN = /\bgetaddrinfo\s+[A-Z_]+\s+([^\s,)]+)/i;
|
|
44
|
+
const CONNECT_HOST_PATTERN = /\b(?:connect|connection to)\s+[A-Z_]+\s+([^\s,)]+?)(?::\d+)?(?=[\s,)]|$)/i;
|
|
45
|
+
const BARE_HOST_PATTERN = /\b((?:[a-z0-9-]+\.)+[a-z]{2,})\b/i;
|
|
46
|
+
const GETADDRINFO_DETAIL_PATTERN = /\bgetaddrinfo\s+[A-Z_]+/i;
|
|
47
|
+
const ERRNO_DETAIL_PATTERN = /\b(?:ETIMEOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|FailedToOpenSocket)\b/i;
|
|
48
|
+
const HOST_PATTERNS = [GETADDRINFO_HOST_PATTERN, CONNECT_HOST_PATTERN, BARE_HOST_PATTERN];
|
|
49
|
+
|
|
50
|
+
interface Link {
|
|
51
|
+
value: unknown;
|
|
52
|
+
depth: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function kindOf(code: string | undefined, message: string | undefined): NetworkFailureKind | undefined {
|
|
56
|
+
// TLS first: a certificate failure usually also reads as a failed connect.
|
|
57
|
+
if (code !== undefined && TLS_CODE_PATTERN.test(code)) return "tls";
|
|
58
|
+
if (message !== undefined && TLS_TEXT_PATTERN.test(message)) return "tls";
|
|
59
|
+
if (code !== undefined && REFUSED_CODE_PATTERN.test(code)) return "refused";
|
|
60
|
+
if (message !== undefined && REFUSED_TEXT_PATTERN.test(message)) return "refused";
|
|
61
|
+
// Offline before unreachable: `getaddrinfo ETIMEOUT` matches both text patterns.
|
|
62
|
+
if (code !== undefined && OFFLINE_CODE_PATTERN.test(code)) return "offline";
|
|
63
|
+
if (message !== undefined && OFFLINE_TEXT_PATTERN.test(message)) return "offline";
|
|
64
|
+
if (code !== undefined && UNREACHABLE_CODE_PATTERN.test(code)) return "unreachable";
|
|
65
|
+
if (message !== undefined && UNREACHABLE_TEXT_PATTERN.test(message)) return "unreachable";
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** First host named by any message seen so far, most specific pattern first. */
|
|
70
|
+
function hostFromTexts(texts: readonly string[]): string | undefined {
|
|
71
|
+
for (const pattern of HOST_PATTERNS) {
|
|
72
|
+
for (const text of texts) {
|
|
73
|
+
const host = pattern.exec(text)?.[1];
|
|
74
|
+
if (host) return host;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function detailOf(kind: NetworkFailureKind, code: string | undefined, message: string | undefined): string | undefined {
|
|
81
|
+
// The refused template already carries its own wording.
|
|
82
|
+
if (kind === "refused") return undefined;
|
|
83
|
+
if (kind === "tls" && code !== undefined) return code;
|
|
84
|
+
const pattern = kind === "tls" ? TLS_TEXT_PATTERN : GETADDRINFO_DETAIL_PATTERN;
|
|
85
|
+
const matched = message === undefined ? undefined : pattern.exec(message)?.[0];
|
|
86
|
+
if (matched !== undefined) return matched.replace(/\s+/g, " ").trim().slice(0, MAX_DETAIL_LENGTH);
|
|
87
|
+
if (kind === "tls") return undefined;
|
|
88
|
+
// A DNS/route failure without a `getaddrinfo …` fragment still names its errno; keeping it in
|
|
89
|
+
// the sentence is what keeps the rendered message matching TRANSIENT_TRANSPORT_PATTERN.
|
|
90
|
+
return code ?? (message === undefined ? undefined : ERRNO_DETAIL_PATTERN.exec(message)?.[0]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walk an error's `cause`/`errors` graph (bounded depth, cycle-safe) and report
|
|
95
|
+
* the outermost link that describes a local transport failure. Returns
|
|
96
|
+
* `undefined` for anything else, including HTTP-level provider errors.
|
|
97
|
+
*/
|
|
98
|
+
export function classifyNetworkCause(err: unknown): NetworkFailure | undefined {
|
|
99
|
+
const seen = new Set<object>();
|
|
100
|
+
const queue: Link[] = [{ value: err, depth: 0 }];
|
|
101
|
+
const texts: string[] = [];
|
|
102
|
+
let links = 0;
|
|
103
|
+
|
|
104
|
+
while (links < MAX_LINKS) {
|
|
105
|
+
const link = queue.shift();
|
|
106
|
+
if (link === undefined) break;
|
|
107
|
+
links++;
|
|
108
|
+
const { value, depth } = link;
|
|
109
|
+
|
|
110
|
+
let code: string | undefined;
|
|
111
|
+
let message: string | undefined;
|
|
112
|
+
const isObject = typeof value === "object" && value !== null;
|
|
113
|
+
if (typeof value === "string") {
|
|
114
|
+
message = value;
|
|
115
|
+
} else if (isObject) {
|
|
116
|
+
if (seen.has(value)) continue;
|
|
117
|
+
seen.add(value);
|
|
118
|
+
if ("code" in value && typeof value.code === "string") code = value.code;
|
|
119
|
+
if ("message" in value && typeof value.message === "string") message = value.message;
|
|
120
|
+
} else {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (message !== undefined) texts.push(message);
|
|
124
|
+
|
|
125
|
+
const kind = kindOf(code, message);
|
|
126
|
+
if (kind !== undefined) {
|
|
127
|
+
return { kind, code, detail: detailOf(kind, code, message), host: hostFromTexts(texts) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!isObject || depth >= MAX_CAUSE_DEPTH) continue;
|
|
131
|
+
if ("cause" in value) queue.push({ value: value.cause, depth: depth + 1 });
|
|
132
|
+
if ("errors" in value && Array.isArray(value.errors)) {
|
|
133
|
+
for (const nested of value.errors.slice(0, MAX_AGGREGATE_BRANCH)) {
|
|
134
|
+
queue.push({ value: nested, depth: depth + 1 });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* User-visible sentence for a network failure, or `undefined` when the error is
|
|
143
|
+
* not one. `requestUrl` wins over any host parsed out of the error text.
|
|
144
|
+
*/
|
|
145
|
+
export function networkFailureMessage(err: unknown, requestUrl?: string): string | undefined {
|
|
146
|
+
const failure = classifyNetworkCause(err);
|
|
147
|
+
if (failure === undefined) return undefined;
|
|
148
|
+
let urlHost: string | undefined;
|
|
149
|
+
if (typeof requestUrl === "string" && requestUrl.length > 0) {
|
|
150
|
+
try {
|
|
151
|
+
const { host } = new URL(requestUrl);
|
|
152
|
+
if (host.length > 0) urlHost = host;
|
|
153
|
+
} catch {
|
|
154
|
+
urlHost = undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const host = urlHost ?? failure.host ?? UNKNOWN_HOST;
|
|
158
|
+
const detail = failure.detail === undefined ? "" : ` (${failure.detail})`;
|
|
159
|
+
switch (failure.kind) {
|
|
160
|
+
case "offline":
|
|
161
|
+
return `No internet connection: couldn't reach ${host}${detail}. Check your network or proxy and try again.`;
|
|
162
|
+
case "unreachable":
|
|
163
|
+
return `Couldn't reach ${host}${detail}. Check the URL, your network, or proxy.`;
|
|
164
|
+
case "refused":
|
|
165
|
+
return `Couldn't connect to ${host} (connection refused). Check that the server is running and reachable.`;
|
|
166
|
+
case "tls":
|
|
167
|
+
return `TLS certificate verification failed for ${host}${detail}. If you're behind a TLS-inspecting proxy, its CA may need to be trusted.`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Re-describe a rejected `fetch` with {@link networkFailureMessage}, keeping the
|
|
173
|
+
* original error as `cause`. Anything that is not a local transport failure is
|
|
174
|
+
* returned untouched, so provider/HTTP errors keep their identity.
|
|
175
|
+
*/
|
|
176
|
+
export function networkFailureError(err: unknown, requestUrl?: string): unknown {
|
|
177
|
+
const message = networkFailureMessage(err, requestUrl);
|
|
178
|
+
return message === undefined ? err : new Error(message, { cause: err });
|
|
179
|
+
}
|
|
@@ -8,7 +8,7 @@ import { getAntigravityUserAgent } from "@linxiraos/pi-catalog/wire/gemini-heade
|
|
|
8
8
|
import { readSseJson } from "@linxiraos/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 "@linxiraos/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 "@linxiraos/pi-catalog/wire/codex";
|
|
10
|
-
import { readSseJson, USER_AGENT } from "@linxiraos/pi-utils";
|
|
10
|
+
import { prompt, readSseJson, USER_AGENT } from "@linxiraos/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
|
|
|
@@ -25,21 +26,24 @@ export async function generateOpenAIImage(
|
|
|
25
26
|
const fetchImpl = options.fetch ?? fetch;
|
|
26
27
|
const size = resolveOpenAIImageSize(request.aspectRatio, request.imageSize);
|
|
27
28
|
const count = request.count ?? 1;
|
|
29
|
+
const wireModelId = model.requestModelId ?? model.id;
|
|
30
|
+
// Hand-built image models (SDK callers, tests) may skip `buildModel` and carry no identity.
|
|
31
|
+
const isGptImage = model.identity?.family === "gpt-image";
|
|
28
32
|
const isXAI = model.provider === "xai" || model.provider === "xai-oauth";
|
|
29
33
|
const generationBody = isXAI
|
|
30
34
|
? {
|
|
31
|
-
model:
|
|
35
|
+
model: wireModelId,
|
|
32
36
|
prompt: request.prompt,
|
|
33
37
|
aspect_ratio: request.aspectRatio ?? "1:1",
|
|
34
38
|
resolution: resolveXAIResolution(request.imageSize),
|
|
35
39
|
n: count,
|
|
36
|
-
response_format: "b64_json",
|
|
40
|
+
...(isGptImage ? {} : { response_format: "b64_json" }),
|
|
37
41
|
}
|
|
38
42
|
: {
|
|
39
|
-
model:
|
|
43
|
+
model: wireModelId,
|
|
40
44
|
prompt: request.prompt,
|
|
41
45
|
n: count,
|
|
42
|
-
response_format: "b64_json",
|
|
46
|
+
...(isGptImage ? {} : { response_format: "b64_json" }),
|
|
43
47
|
...(size ? { size } : {}),
|
|
44
48
|
};
|
|
45
49
|
const inputImages = request.inputImages ?? [];
|
|
@@ -79,12 +83,13 @@ export async function generateOpenAIImage(
|
|
|
79
83
|
try {
|
|
80
84
|
if (model.provider === "openai") {
|
|
81
85
|
const form = new FormData();
|
|
82
|
-
form.set("model",
|
|
86
|
+
form.set("model", wireModelId);
|
|
83
87
|
form.set("prompt", request.prompt);
|
|
84
88
|
form.set("n", String(count));
|
|
85
|
-
form.set("response_format", "b64_json");
|
|
89
|
+
if (!isGptImage) form.set("response_format", "b64_json");
|
|
86
90
|
if (size) form.set("size", size);
|
|
87
91
|
for (const image of inputImages) {
|
|
92
|
+
validateImageInput(image);
|
|
88
93
|
form.append("image", new File([Buffer.from(image.data, "base64")], "image", { type: image.mimeType }));
|
|
89
94
|
}
|
|
90
95
|
response = await postMultipart({
|
package/src/images/shared.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { FetchImpl, Model, Usage } from "@linxiraos/pi-catalog/types";
|
|
2
|
-
import { parseImageMetadata, USER_AGENT } from "@linxiraos/pi-utils";
|
|
2
|
+
import { base64ImageDataFailureReason, isRecord, parseImageMetadata, USER_AGENT } from "@linxiraos/pi-utils";
|
|
3
3
|
import type { ApiKey } from "../auth-retry";
|
|
4
4
|
import { withAuth } from "../auth-retry";
|
|
5
5
|
import * as AIError from "../error";
|
|
6
|
-
import type {
|
|
6
|
+
import type { ImageContent } from "../types";
|
|
7
|
+
import type { GeneratedImage, ImageInput } from "./types";
|
|
7
8
|
|
|
8
9
|
export class ImageApiError extends AIError.ProviderHttpError {
|
|
9
10
|
override readonly name = "ImageApiError";
|
|
@@ -185,18 +186,91 @@ export function sniffBase64ImageMimeType(base64: string): string | undefined {
|
|
|
185
186
|
return parseImageMetadata(Buffer.from(base64.slice(0, 64), "base64"))?.mimeType;
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Validate provider-bound image input without changing it. Accepts non-empty standard base64
|
|
191
|
+
* with canonical trailing bits and optional padding, and case-insensitive image MIME syntax
|
|
192
|
+
* (RFC 6838 restricted names). Does not sniff the MIME type, decode image pixels, or enforce
|
|
193
|
+
* provider-specific format support; data-URL transports additionally go through `toDataUrl`.
|
|
194
|
+
*
|
|
195
|
+
* @throws {AIError.ValidationError} If the base64 data or image MIME type is invalid.
|
|
196
|
+
*/
|
|
197
|
+
export function validateImageInput(image: ImageInput): void {
|
|
198
|
+
if (!isRecord(image) || typeof image.data !== "string") {
|
|
199
|
+
throw new AIError.ValidationError("invalid base64 image data");
|
|
200
|
+
}
|
|
201
|
+
const reason = base64ImageDataFailureReason(image.data);
|
|
202
|
+
if (reason) throw new AIError.ValidationError(reason);
|
|
203
|
+
if (!isImageMimeType(image.mimeType)) throw new AIError.ValidationError("invalid image MIME type");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Encode validated image input as a base64 data URL.
|
|
208
|
+
*
|
|
209
|
+
* @throws {AIError.ValidationError} If the input is invalid or its MIME type cannot survive a data URL.
|
|
210
|
+
*/
|
|
211
|
+
export function toDataUrl(image: ImageInput): string {
|
|
212
|
+
validateImageInput(image);
|
|
213
|
+
if (!isDataUrlMimeType(image.mimeType)) {
|
|
214
|
+
throw new AIError.ValidationError("image MIME type containing '#' cannot be sent as a data URL");
|
|
215
|
+
}
|
|
189
216
|
return `data:${image.mimeType};base64,${image.data}`;
|
|
190
217
|
}
|
|
191
218
|
|
|
219
|
+
// RFC 6838 restricted-name.
|
|
220
|
+
const IMAGE_MIME_TYPE = /^image\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/i;
|
|
221
|
+
const IMAGE_DETAILS: Record<string, true> = {
|
|
222
|
+
auto: true,
|
|
223
|
+
low: true,
|
|
224
|
+
high: true,
|
|
225
|
+
original: true,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
function isImageMimeType(value: unknown): value is string {
|
|
229
|
+
return typeof value === "string" && IMAGE_MIME_TYPE.test(value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether a MIME type survives a data URL intact: a raw `#` starts a URL fragment and
|
|
234
|
+
* data-URL consumers leave a `%23` escape undecoded, so no data-URL transport preserves it.
|
|
235
|
+
*/
|
|
236
|
+
export function isDataUrlMimeType(mimeType: string): boolean {
|
|
237
|
+
return !mimeType.includes("#");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isEmbeddedImageShape(image: unknown): image is ImageContent {
|
|
241
|
+
return (
|
|
242
|
+
isRecord(image) &&
|
|
243
|
+
image.type === "image" &&
|
|
244
|
+
typeof image.data === "string" &&
|
|
245
|
+
isImageMimeType(image.mimeType) &&
|
|
246
|
+
isDataUrlMimeType(image.mimeType) &&
|
|
247
|
+
(image.detail === undefined || (typeof image.detail === "string" && Object.hasOwn(IMAGE_DETAILS, image.detail)))
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Whether a value is an embedded image with valid base64 data, MIME type, and detail. */
|
|
252
|
+
export function isEmbeddedImageContent(image: unknown): image is ImageContent {
|
|
253
|
+
return isEmbeddedImageShape(image) && base64ImageDataFailureReason(image.data) === undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Convert one validated embedded image to a lowercase-MIME inline data URL. */
|
|
257
|
+
export function embeddedImageDataUrl(image: unknown): string {
|
|
258
|
+
if (!isEmbeddedImageShape(image)) {
|
|
259
|
+
throw new AIError.ValidationError("Judgment images must contain non-empty base64 data and an image MIME type");
|
|
260
|
+
}
|
|
261
|
+
return toDataUrl({ data: image.data, mimeType: image.mimeType.toLowerCase() });
|
|
262
|
+
}
|
|
263
|
+
|
|
192
264
|
export function resolveOpenAIImageSize(aspectRatio?: string, imageSize?: string): string | undefined {
|
|
193
265
|
if (imageSize) return imageSize;
|
|
194
266
|
switch (aspectRatio) {
|
|
195
267
|
case "1:1":
|
|
196
268
|
return "1024x1024";
|
|
269
|
+
case "2:3":
|
|
197
270
|
case "3:4":
|
|
198
271
|
case "9:16":
|
|
199
272
|
return "1024x1536";
|
|
273
|
+
case "3:2":
|
|
200
274
|
case "4:3":
|
|
201
275
|
case "16:9":
|
|
202
276
|
return "1536x1024";
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ export * from "./utils/anthropic-auth";
|
|
|
65
65
|
export * from "./utils/event-stream";
|
|
66
66
|
export * from "./utils/glyph-codec";
|
|
67
67
|
export * from "./utils/openrouter-headers";
|
|
68
|
+
export * from "./utils/narration";
|
|
68
69
|
export * from "./utils/schema";
|
|
69
70
|
export * from "./utils/thinking-loop";
|
|
70
71
|
export * from "./utils/tool-call-loop-guard";
|
package/src/judgment/chat.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { type } from "@linxiraos/pi-omptype";
|
|
6
6
|
import * as AIError from "../error";
|
|
7
7
|
import { retryTransientCompletion } from "../oneshot-retry";
|
|
8
|
+
import { sendsImageInputOnWire } from "../providers/vision-guard";
|
|
8
9
|
import { completeSimple } from "../stream";
|
|
9
10
|
import type { Api, AssistantMessage, Model, SimpleStreamOptions, Tool } from "../types";
|
|
10
11
|
import type { TextBackend, TextCompletion, TextPrompt } from "./text";
|
|
@@ -40,19 +41,34 @@ export type ChatTextBackendOptions = Pick<SimpleStreamOptions, "apiKey" | "sessi
|
|
|
40
41
|
|
|
41
42
|
/** Chat completions with reasoning disabled, temperature 0, and transient-failure retry. */
|
|
42
43
|
export function chatTextBackend(model: Model<Api>, options: ChatTextBackendOptions): TextBackend {
|
|
44
|
+
const supportsImages = sendsImageInputOnWire(model);
|
|
43
45
|
return {
|
|
44
46
|
api: model.api,
|
|
45
47
|
provider: model.provider,
|
|
46
48
|
model: model.id,
|
|
49
|
+
...(supportsImages ? { supportsImages: true } : {}),
|
|
47
50
|
parseRetries: 2,
|
|
48
51
|
async complete(prompt: TextPrompt, judge: JudgeOptions): Promise<TextCompletion> {
|
|
52
|
+
if (prompt.images?.length && !supportsImages) {
|
|
53
|
+
throw new AIError.ValidationError(
|
|
54
|
+
`Chat judgment backend ${model.provider}/${model.id} does not support image inputs`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
49
57
|
const response = await retryTransientCompletion(
|
|
50
58
|
() =>
|
|
51
59
|
completeSimple(
|
|
52
60
|
model,
|
|
53
61
|
{
|
|
54
62
|
systemPrompt: [prompt.system],
|
|
55
|
-
messages: [
|
|
63
|
+
messages: [
|
|
64
|
+
{
|
|
65
|
+
role: "user",
|
|
66
|
+
content: prompt.images?.length
|
|
67
|
+
? [{ type: "text" as const, text: prompt.user }, ...prompt.images]
|
|
68
|
+
: prompt.user,
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
56
72
|
tools: prompt.retry ? [SUBMIT_JUDGMENT] : undefined,
|
|
57
73
|
},
|
|
58
74
|
{
|