pi-roundtable 0.17.3 → 0.19.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 +29 -0
- package/docs/plugins.md +93 -5
- package/package.json +8 -6
- package/src/core/builtin/discord.ts +11 -0
- package/src/core/discord/discord-surface.ts +8 -0
- package/src/core/discord/poster/poster-checks.ts +272 -0
- package/src/core/discord/poster/poster-errors.ts +106 -0
- package/src/core/discord/poster/poster.ts +301 -0
- package/src/core/discord/poster/rest-poster-core.ts +112 -0
- package/src/core/discord/poster/rest-poster-guild.ts +329 -0
- package/src/core/discord/poster/rest-poster.ts +488 -0
- package/src/core/images/codex-image.ts +257 -0
- package/src/core/images/codex-images-plugin.ts +76 -0
- package/src/core/modules/delegation/web-research-worker.ts +1 -1
- package/src/core/ops/alert-throttle.ts +81 -0
- package/src/core/ops/error-reporter.ts +20 -17
- package/src/core/ops/ops-alerts.ts +132 -0
- package/src/core/testing/fake-poster-guild.ts +491 -0
- package/src/core/testing/fake-poster.ts +475 -0
- package/src/core/testing/test-host.ts +16 -5
- package/src/discord/index.ts +21 -0
- package/src/kit/images.ts +15 -0
- package/src/kit/index.ts +21 -0
- package/src/kit/jev-judge.ts +153 -0
- package/src/kit/ops-alerts.ts +9 -0
- package/src/testing.ts +15 -1
- package/templates/adapters/web/package.json.tmpl +2 -2
- package/templates/official/codex-images/plugin.test.ts.tmpl +31 -0
- package/templates/official/codex-images/plugin.ts +8 -232
- package/templates/package.json.tmpl +2 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// A Judge over Jev's hosted service, built on pi-jev-compaction's client.
|
|
2
|
+
|
|
3
|
+
import { JevClient, type JevQuestions } from "pi-jev-compaction";
|
|
4
|
+
import type {
|
|
5
|
+
ChoiceAnswer,
|
|
6
|
+
ChoiceQuestion,
|
|
7
|
+
Judge,
|
|
8
|
+
ScoreQuestion,
|
|
9
|
+
YesNoQuestion,
|
|
10
|
+
} from "../core/contract/providers.ts";
|
|
11
|
+
import { JudgeError } from "../core/errors.ts";
|
|
12
|
+
|
|
13
|
+
/** The service's name for the yes-or-no question and for its answer's probability. */
|
|
14
|
+
const WIRE_YES_NO = "noul";
|
|
15
|
+
type JevQuestion = JevQuestions[string];
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
17
|
+
|
|
18
|
+
export interface JevJudgeOptions {
|
|
19
|
+
/** The service's API key. It is required: an empty one is refused, not looked up in the environment. */
|
|
20
|
+
apiKey: string;
|
|
21
|
+
/** The service's model; default pi-jev-compaction's. */
|
|
22
|
+
model?: string;
|
|
23
|
+
/** The service's endpoint; default pi-jev-compaction's. */
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
/** How long one question may take, the read of the answer included; default 20,000. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** The request function; default the global `fetch`. */
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// pi-lens-ignore: no-unknown-parameters — a type guard over parsed JSON
|
|
32
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
33
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
|
|
35
|
+
// pi-lens-ignore: no-unknown-parameters — a type guard over parsed JSON
|
|
36
|
+
const isProbability = (value: unknown): value is number =>
|
|
37
|
+
typeof value === "number" && value >= 0 && value <= 1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A `Judge` that asks Jev. Each question is one request with its own time limit of `timeoutMs`,
|
|
41
|
+
* which also covers reading the answer. Every failure throws a `JudgeError`: a missing answer,
|
|
42
|
+
* a probability outside 0 to 1, a choice that is not one of the listed options, a failed request,
|
|
43
|
+
* and a limit that ran out. An empty `apiKey` is refused when the judge is made.
|
|
44
|
+
*/
|
|
45
|
+
export function jevJudge(options: JevJudgeOptions): Judge {
|
|
46
|
+
const apiKey = options.apiKey.trim();
|
|
47
|
+
if (!apiKey)
|
|
48
|
+
throw new JudgeError(
|
|
49
|
+
"jevJudge needs an apiKey; an empty one would fall back to the environment's key",
|
|
50
|
+
);
|
|
51
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
52
|
+
|
|
53
|
+
async function ask(
|
|
54
|
+
state: Record<string, unknown>,
|
|
55
|
+
questions: JevQuestions,
|
|
56
|
+
): Promise<Record<string, unknown>> {
|
|
57
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
58
|
+
const client = new JevClient({
|
|
59
|
+
apiKey,
|
|
60
|
+
model: options.model,
|
|
61
|
+
baseUrl: options.baseUrl,
|
|
62
|
+
fetch: options.fetch,
|
|
63
|
+
signal,
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
return (await client.ask(state, questions)).answers;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error instanceof JudgeError) throw error;
|
|
69
|
+
if (signal.aborted)
|
|
70
|
+
throw new JudgeError(`Jev did not answer within ${timeoutMs} ms`, {
|
|
71
|
+
cause: error,
|
|
72
|
+
});
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
throw new JudgeError(
|
|
75
|
+
/^Jev/.test(message) ? message : `Jev request failed: ${message}`,
|
|
76
|
+
{ cause: error },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const malformed = (name: string, detail = "") =>
|
|
82
|
+
new JudgeError(`Jev's answer for ${name} is malformed${detail}`);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
async askYesNo(
|
|
86
|
+
state: Record<string, unknown>,
|
|
87
|
+
questions: Record<string, YesNoQuestion>,
|
|
88
|
+
): Promise<Record<string, number>> {
|
|
89
|
+
const names = Object.keys(questions);
|
|
90
|
+
if (names.length === 0) return {};
|
|
91
|
+
const answers = await ask(
|
|
92
|
+
state,
|
|
93
|
+
Object.fromEntries(
|
|
94
|
+
names.map((name) => {
|
|
95
|
+
const { type: _type, ...question } = questions[
|
|
96
|
+
name
|
|
97
|
+
] as YesNoQuestion;
|
|
98
|
+
return [name, { ...question, type: WIRE_YES_NO } as JevQuestion];
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
// pi-lens-ignore: no-known-value-widening — filled per question name below
|
|
103
|
+
const result: Record<string, number> = {};
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
const answer = answers[name];
|
|
106
|
+
const probability = isRecord(answer) ? answer[WIRE_YES_NO] : undefined;
|
|
107
|
+
if (!isProbability(probability)) throw malformed(name);
|
|
108
|
+
result[name] = probability;
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async askChoice(
|
|
114
|
+
state: Record<string, unknown>,
|
|
115
|
+
name: string,
|
|
116
|
+
question: ChoiceQuestion,
|
|
117
|
+
): Promise<ChoiceAnswer> {
|
|
118
|
+
const answer = (await ask(state, { [name]: question }))[name];
|
|
119
|
+
if (
|
|
120
|
+
!isRecord(answer) ||
|
|
121
|
+
typeof answer.choice !== "string" ||
|
|
122
|
+
!isProbability(answer.confidence)
|
|
123
|
+
)
|
|
124
|
+
throw malformed(name);
|
|
125
|
+
if (!Object.hasOwn(question.criteria, answer.choice))
|
|
126
|
+
throw new JudgeError(
|
|
127
|
+
`Jev chose ${answer.choice}, which is not an option of ${name}`,
|
|
128
|
+
);
|
|
129
|
+
return { choice: answer.choice, confidence: answer.confidence };
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async askScore(
|
|
133
|
+
state: Record<string, unknown>,
|
|
134
|
+
name: string,
|
|
135
|
+
question: ScoreQuestion,
|
|
136
|
+
): Promise<number[]> {
|
|
137
|
+
if (question.criteria.length === 0) return [];
|
|
138
|
+
const answer = (
|
|
139
|
+
await ask(state, {
|
|
140
|
+
[name]: { ...question, criteria: [...question.criteria] },
|
|
141
|
+
})
|
|
142
|
+
)[name];
|
|
143
|
+
const probabilities = isRecord(answer) ? answer.probabilities : undefined;
|
|
144
|
+
if (!isRecord(probabilities)) throw malformed(name);
|
|
145
|
+
return question.criteria.map((_, index) => {
|
|
146
|
+
const value = probabilities[String(index)];
|
|
147
|
+
if (!isProbability(value))
|
|
148
|
+
throw malformed(name, `: no probability for criterion ${index}`);
|
|
149
|
+
return value;
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
|
+
// Short alerts for the people who run a host, held to a window so a fault cannot flood them.
|
|
3
|
+
|
|
4
|
+
export type {
|
|
5
|
+
OpsAlertKind,
|
|
6
|
+
OpsAlertsOptions,
|
|
7
|
+
OpsRaiseOptions,
|
|
8
|
+
} from "../core/ops/ops-alerts.ts";
|
|
9
|
+
export { OpsAlerts } from "../core/ops/ops-alerts.ts";
|
package/src/testing.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
commandGuard,
|
|
30
30
|
ownerRootCommand,
|
|
31
31
|
} from "./core/discord/owner-command.ts";
|
|
32
|
+
import type { DiscordPoster } from "./core/discord/poster/poster.ts";
|
|
32
33
|
import type { ClientPart, ClientPort } from "./core/domain/client.ts";
|
|
33
34
|
import type {
|
|
34
35
|
PendingConfirmation,
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
} from "./core/services.ts";
|
|
77
78
|
import type { ChannelKey, SessionContext } from "./core/sessions.ts";
|
|
78
79
|
import { type Speaker, THE_SPEAKER } from "./core/speakers.ts";
|
|
80
|
+
import { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
|
|
79
81
|
import { mapIdentity } from "./core/testing/map-identity.ts";
|
|
80
82
|
import {
|
|
81
83
|
callRegisteredTool,
|
|
@@ -95,6 +97,12 @@ export {
|
|
|
95
97
|
testDatabaseUrl,
|
|
96
98
|
} from "./core/testing/database.ts";
|
|
97
99
|
export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
|
|
100
|
+
export type {
|
|
101
|
+
FakeDiscordPoster,
|
|
102
|
+
FakePosterChannel,
|
|
103
|
+
FakePosterMessage,
|
|
104
|
+
} from "./core/testing/fake-poster.ts";
|
|
105
|
+
export { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
|
|
98
106
|
export type { TestLocale } from "./core/testing/locale.ts";
|
|
99
107
|
export { useTestLocale } from "./core/testing/locale.ts";
|
|
100
108
|
export { OWNER_SPEAKER } from "./core/testing/owner.ts";
|
|
@@ -156,6 +164,8 @@ export interface FakeDiscord {
|
|
|
156
164
|
readonly service: ServicePair;
|
|
157
165
|
/** What `DISCORD.commands` is to the plugin: the real registrar, which refuses what the Discord plugin refuses. */
|
|
158
166
|
readonly commands: CommandRegistrar;
|
|
167
|
+
/** What `DISCORD.poster` is: the one the test gave, else a `fakeDiscordPoster()`. */
|
|
168
|
+
readonly poster: DiscordPoster;
|
|
159
169
|
/** What `DISCORD.guard` is: the owner, `owner` unless the test gave another id, serving the root command. */
|
|
160
170
|
readonly guard: CommandGuard;
|
|
161
171
|
/** Every contribution the plugin added, in the order it added them. */
|
|
@@ -169,13 +179,14 @@ export interface FakeDiscord {
|
|
|
169
179
|
* plugin's `commands.add` calls are recorded, and `compose()` shows the tree Discord would get.
|
|
170
180
|
* `rootCommand` is the root command's name, `roundtable` by default. `channelContext` is what
|
|
171
181
|
* `DISCORD.channelContext` answers, by default no context, as on a host with it off. Only
|
|
172
|
-
* `commands`, `guard`, and `channelContext` are given; reading another member throws.
|
|
182
|
+
* `commands`, `guard`, `poster`, and `channelContext` are given; reading another member throws.
|
|
173
183
|
*/
|
|
174
184
|
export function fakeDiscord(
|
|
175
185
|
options: {
|
|
176
186
|
ownerId?: string;
|
|
177
187
|
rootCommand?: string;
|
|
178
188
|
channelContext?: DiscordServices["channelContext"];
|
|
189
|
+
poster?: DiscordPoster;
|
|
179
190
|
} = {},
|
|
180
191
|
): FakeDiscord {
|
|
181
192
|
const collection = new CommandCollection();
|
|
@@ -185,12 +196,15 @@ export function fakeDiscord(
|
|
|
185
196
|
root: rootCommand,
|
|
186
197
|
logger: silentLogger(),
|
|
187
198
|
});
|
|
199
|
+
const poster = options.poster ?? fakeDiscordPoster();
|
|
188
200
|
return {
|
|
189
201
|
service: servicePair(DISCORD, {
|
|
190
202
|
commands: collection.registrar,
|
|
191
203
|
guard,
|
|
204
|
+
poster,
|
|
192
205
|
channelContext: options.channelContext ?? (async () => undefined),
|
|
193
206
|
}),
|
|
207
|
+
poster,
|
|
194
208
|
commands: collection.registrar,
|
|
195
209
|
guard,
|
|
196
210
|
added: () => collection.added(),
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"pi-roundtable": "__VERSION__",
|
|
17
17
|
"pi-roundtable-webchat": "__VERSION__",
|
|
18
18
|
"pi-self-compact": "0.2.0",
|
|
19
|
-
"typebox": "1.3.
|
|
19
|
+
"typebox": "1.3.37"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@biomejs/biome": "2.5.15",
|
|
23
|
-
"@types/bun": "1.4.
|
|
23
|
+
"@types/bun": "1.4.3",
|
|
24
24
|
"typescript": "7.0.2"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -107,6 +107,37 @@ test("codex-images reports an HTTP error with its status, and never the token",
|
|
|
107
107
|
await harness.stop();
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("codex-images aborts a drawing with the turn that asked for it", async () => {
|
|
111
|
+
const signals: AbortSignal[] = [];
|
|
112
|
+
const plugin = createCodexImages({
|
|
113
|
+
fetch: (_url, init) => {
|
|
114
|
+
const signal = init.signal as AbortSignal;
|
|
115
|
+
signals.push(signal);
|
|
116
|
+
return new Promise((_resolve, reject) => {
|
|
117
|
+
signal.addEventListener("abort", () => reject(signal.reason));
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const harness = await testPlugin(plugin, {
|
|
122
|
+
apiKeys: { "openai-codex": token("acct-1") },
|
|
123
|
+
});
|
|
124
|
+
const turn = new AbortController();
|
|
125
|
+
const reason = new Error("the turn ended");
|
|
126
|
+
const drawing = plugin.providers?.images?.("a fox", [], {
|
|
127
|
+
signal: turn.signal,
|
|
128
|
+
});
|
|
129
|
+
const outcome = drawing?.then(
|
|
130
|
+
() => undefined,
|
|
131
|
+
(error: unknown) => error,
|
|
132
|
+
);
|
|
133
|
+
await new Promise((done) => setTimeout(done, 20));
|
|
134
|
+
expect(signals[0]?.aborted).toBe(false);
|
|
135
|
+
turn.abort(reason);
|
|
136
|
+
expect(await outcome).toBe(reason);
|
|
137
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
138
|
+
await harness.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
110
141
|
test("codex-images refuses to start without an openai-codex login", async () => {
|
|
111
142
|
const failure = await testPlugin(codexImages).then(
|
|
112
143
|
() => undefined,
|
|
@@ -3,238 +3,14 @@
|
|
|
3
3
|
// as an API, so it can stop working without notice, and OpenAI's terms for the subscription
|
|
4
4
|
// apply to it. Use it only where you accept that risk.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { createCodexImages } from "pi-roundtable/kit";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
-
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
16
|
-
const MAX_IMAGE_BASE64 = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Codex ran the image tool but returned no image, usually because it refused the prompt.
|
|
20
|
-
* Asking again with the same prompt fails again.
|
|
21
|
-
*/
|
|
22
|
-
export class ImageNotGeneratedError extends Error {
|
|
23
|
-
override name = "ImageNotGeneratedError";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** The part of `fetch` the plugin uses, so a test can stand in for the network. */
|
|
27
|
-
export type CodexFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
28
|
-
|
|
29
|
-
export interface CodexImagesOptions {
|
|
30
|
-
/** The request function; default the global `fetch`. */
|
|
31
|
-
fetch?: CodexFetch;
|
|
32
|
-
/** The model that routes the request; default `gpt-6-sol`. */
|
|
33
|
-
model?: string;
|
|
34
|
-
/**
|
|
35
|
-
* What setup does when the openai-codex login is missing or unusable: "throw" (the default)
|
|
36
|
-
* stops the host; "warn" logs a warning, and a drawing then fails with the same PluginError.
|
|
37
|
-
*/
|
|
38
|
-
onMissingLogin?: "throw" | "warn";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The ChatGPT account the login token belongs to, from the token's claims. */
|
|
42
|
-
function chatGptAccountId(token: string): string {
|
|
43
|
-
const [, payload] = token.split(".");
|
|
44
|
-
if (!payload) throw new Error("the Codex token is not a JWT");
|
|
45
|
-
let claims: Record<string, unknown>;
|
|
46
|
-
try {
|
|
47
|
-
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
48
|
-
} catch {
|
|
49
|
-
throw new Error("the Codex token's claims are not JSON");
|
|
50
|
-
}
|
|
51
|
-
const auth = claims[JWT_CLAIM_PATH] as
|
|
52
|
-
| { chatgpt_account_id?: unknown }
|
|
53
|
-
| undefined;
|
|
54
|
-
const id = auth?.chatgpt_account_id;
|
|
55
|
-
if (typeof id !== "string" || !id)
|
|
56
|
-
throw new Error("the Codex token has no ChatGPT account");
|
|
57
|
-
return id;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function object(value: unknown): Record<string, unknown> {
|
|
61
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
62
|
-
? (value as Record<string, unknown>)
|
|
63
|
-
: {};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** The one image an `image_generation_call` item carries; anything else is not an image. */
|
|
67
|
-
function imageOf(value: unknown): Uint8Array | undefined {
|
|
68
|
-
const item = object(value);
|
|
69
|
-
if (item.type !== "image_generation_call") return undefined;
|
|
70
|
-
if (
|
|
71
|
-
item.status !== "completed" ||
|
|
72
|
-
typeof item.result !== "string" ||
|
|
73
|
-
!item.result
|
|
74
|
-
) {
|
|
75
|
-
const { result: _result, ...detail } = item;
|
|
76
|
-
throw new ImageNotGeneratedError(
|
|
77
|
-
`Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
if (item.result.length > MAX_IMAGE_BASE64)
|
|
81
|
-
throw new Error("the image exceeds 32 MiB");
|
|
82
|
-
return new Uint8Array(Buffer.from(item.result, "base64"));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** The frames of a server-sent event stream: the text between blank lines. */
|
|
86
|
-
async function* frames(
|
|
87
|
-
body: ReadableStream<Uint8Array>,
|
|
88
|
-
): AsyncGenerator<string> {
|
|
89
|
-
const decoder = new TextDecoder();
|
|
90
|
-
let buffer = "";
|
|
91
|
-
for await (const chunk of body) {
|
|
92
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
93
|
-
let match = /\r?\n\r?\n/.exec(buffer);
|
|
94
|
-
while (match) {
|
|
95
|
-
yield buffer.slice(0, match.index);
|
|
96
|
-
buffer = buffer.slice(match.index + match[0].length);
|
|
97
|
-
match = /\r?\n\r?\n/.exec(buffer);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (buffer.trim()) yield buffer;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** The event a frame carries, or undefined for a frame without data. */
|
|
104
|
-
function eventOf(frame: string): Record<string, unknown> | undefined {
|
|
105
|
-
const data = frame
|
|
106
|
-
.split(/\r?\n/)
|
|
107
|
-
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trim()] : []))
|
|
108
|
-
.join("\n");
|
|
109
|
-
if (!data || data === "[DONE]") return undefined;
|
|
110
|
-
try {
|
|
111
|
-
return object(JSON.parse(data));
|
|
112
|
-
} catch {
|
|
113
|
-
throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Reads the events until the response completes, and returns its one image. */
|
|
118
|
-
async function parseImageStream(response: Response): Promise<Uint8Array> {
|
|
119
|
-
if (!response.body) throw new Error("Codex returned no body");
|
|
120
|
-
let image: Uint8Array | undefined;
|
|
121
|
-
for await (const frame of frames(response.body)) {
|
|
122
|
-
const event = eventOf(frame);
|
|
123
|
-
switch (event?.type) {
|
|
124
|
-
case "error":
|
|
125
|
-
case "response.failed": {
|
|
126
|
-
const error = object(object(event.response).error ?? event.error);
|
|
127
|
-
throw new Error(
|
|
128
|
-
`Codex failed: ${String(error.message ?? error.code ?? "unknown error")}`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
case "response.incomplete":
|
|
132
|
-
throw new Error("the Codex response was incomplete");
|
|
133
|
-
case "response.output_item.done":
|
|
134
|
-
image ??= imageOf(event.item);
|
|
135
|
-
break;
|
|
136
|
-
case "response.completed": {
|
|
137
|
-
const output = object(event.response).output;
|
|
138
|
-
if (Array.isArray(output))
|
|
139
|
-
for (const item of output) image ??= imageOf(item);
|
|
140
|
-
if (!image)
|
|
141
|
-
throw new ImageNotGeneratedError("Codex answered without an image");
|
|
142
|
-
return image;
|
|
143
|
-
}
|
|
144
|
-
default:
|
|
145
|
-
break;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
throw new Error("the Codex stream ended before completion");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function generateImage(
|
|
152
|
-
prompt: string,
|
|
153
|
-
references: readonly ReferenceImage[],
|
|
154
|
-
token: string,
|
|
155
|
-
model: string,
|
|
156
|
-
fetchImpl: CodexFetch,
|
|
157
|
-
): Promise<Uint8Array> {
|
|
158
|
-
const response = await fetchImpl(CODEX_RESPONSES_URL, {
|
|
159
|
-
method: "POST",
|
|
160
|
-
redirect: "error",
|
|
161
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
162
|
-
headers: {
|
|
163
|
-
authorization: `Bearer ${token}`,
|
|
164
|
-
"chatgpt-account-id": chatGptAccountId(token),
|
|
165
|
-
originator: "pi",
|
|
166
|
-
"openai-beta": "responses=experimental",
|
|
167
|
-
accept: "text/event-stream",
|
|
168
|
-
"content-type": "application/json",
|
|
169
|
-
},
|
|
170
|
-
body: JSON.stringify({
|
|
171
|
-
model,
|
|
172
|
-
store: false,
|
|
173
|
-
stream: true,
|
|
174
|
-
instructions:
|
|
175
|
-
"You are generating bitmap image assets. For this request, call the image_generation tool exactly once. Do not answer with only text unless image generation is unavailable.",
|
|
176
|
-
input: [
|
|
177
|
-
{
|
|
178
|
-
role: "user",
|
|
179
|
-
content: [
|
|
180
|
-
{ type: "input_text", text: prompt },
|
|
181
|
-
...references.map((image) => ({
|
|
182
|
-
type: "input_image",
|
|
183
|
-
image_url: `data:${image.mimeType};base64,${image.data}`,
|
|
184
|
-
})),
|
|
185
|
-
],
|
|
186
|
-
},
|
|
187
|
-
],
|
|
188
|
-
tools: [{ type: "image_generation", output_format: "png" }],
|
|
189
|
-
tool_choice: "auto",
|
|
190
|
-
parallel_tool_calls: false,
|
|
191
|
-
text: { verbosity: "low" },
|
|
192
|
-
}),
|
|
193
|
-
});
|
|
194
|
-
if (!response.ok) {
|
|
195
|
-
const detail = (await response.text()).slice(0, 300);
|
|
196
|
-
throw new Error(`Codex answered ${response.status}: ${detail}`);
|
|
197
|
-
}
|
|
198
|
-
return parseImageStream(response);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const NO_LOGIN = `the host has no login for ${CODEX_PROVIDER}, the provider that pays for the images. Log in to the ${CODEX_PROVIDER} provider with Pi so the agent directory's auth.json holds it, or remove this plugin.`;
|
|
202
|
-
|
|
203
|
-
/** The plugin, with its request function and model replaceable for a test. */
|
|
204
|
-
export function createCodexImages(options: CodexImagesOptions = {}) {
|
|
205
|
-
const {
|
|
206
|
-
fetch: fetchImpl = fetch,
|
|
207
|
-
model = CODEX_MODEL,
|
|
208
|
-
onMissingLogin = "throw",
|
|
209
|
-
} = options;
|
|
210
|
-
let apiKey: ((provider: string) => Promise<string | undefined>) | undefined;
|
|
211
|
-
// The login refreshes its token, so each image reads it again instead of keeping the first.
|
|
212
|
-
const token = async (): Promise<string> => {
|
|
213
|
-
const value = await apiKey?.(CODEX_PROVIDER);
|
|
214
|
-
if (!value) throw new PluginError(`plugin codex-images: ${NO_LOGIN}`);
|
|
215
|
-
return value;
|
|
216
|
-
};
|
|
217
|
-
return definePlugin({
|
|
218
|
-
name: "codex-images",
|
|
219
|
-
providers: {
|
|
220
|
-
// The images slot draws an agent's avatar from a prompt and reference pictures.
|
|
221
|
-
images: async (prompt, references) =>
|
|
222
|
-
generateImage(prompt, references, await token(), model, fetchImpl),
|
|
223
|
-
},
|
|
224
|
-
async setup(context) {
|
|
225
|
-
apiKey = context.apiKey;
|
|
226
|
-
try {
|
|
227
|
-
await token();
|
|
228
|
-
} catch (error) {
|
|
229
|
-
if (onMissingLogin !== "warn" || !(error instanceof PluginError))
|
|
230
|
-
throw error;
|
|
231
|
-
context.logger.warn(
|
|
232
|
-
`${NO_LOGIN} Drawing images fails until the login exists.`,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
return {};
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
}
|
|
8
|
+
export {
|
|
9
|
+
type CodexFetch,
|
|
10
|
+
type CodexImagesOptions,
|
|
11
|
+
createCodexImages,
|
|
12
|
+
ImageNotGeneratedError,
|
|
13
|
+
} from "pi-roundtable/kit";
|
|
239
14
|
|
|
15
|
+
/** The plugin the project lists. The client and the plugin are the kit's; this file is yours to change. */
|
|
240
16
|
export const codexImages = createCodexImages();
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"pi-roundtable": "__VERSION__",
|
|
17
17
|
"pi-self-compact": "0.2.0",
|
|
18
|
-
"typebox": "1.3.
|
|
18
|
+
"typebox": "1.3.37"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@biomejs/biome": "2.5.15",
|
|
22
|
-
"@types/bun": "1.4.
|
|
22
|
+
"@types/bun": "1.4.3",
|
|
23
23
|
"typescript": "7.0.2"
|
|
24
24
|
}
|
|
25
25
|
}
|