@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.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 +26 -0
- package/dist/cli.js +3336 -3318
- package/dist/types/advisor/advise-tool.d.ts +12 -1
- package/dist/types/advisor/runtime.d.ts +41 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-cli.d.ts +3 -0
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +8 -1
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
- package/dist/types/eval/bridge-timeout.d.ts +9 -1
- package/dist/types/eval/js/context-manager.d.ts +5 -3
- package/dist/types/eval/js/process-entry.d.ts +6 -0
- package/dist/types/eval/js/worker-core.d.ts +15 -1
- package/dist/types/eval/py/spawn-options.d.ts +10 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/exit-diagnostics.d.ts +11 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
- package/dist/types/subprocess/worker-client.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/web/search/provider.d.ts +10 -3
- package/dist/types/web/search/providers/codex.d.ts +5 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +830 -42
- package/src/advisor/advise-tool.ts +17 -1
- package/src/advisor/runtime.ts +288 -67
- package/src/autolearn/controller.ts +15 -3
- package/src/cli/args.ts +12 -0
- package/src/cli/auth-broker-cli.ts +30 -11
- package/src/cli/auth-gateway-cli.ts +5 -1
- package/src/cli/dry-balance-cli.ts +14 -4
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +62 -11
- package/src/cli/usage-cli.ts +58 -5
- package/src/cli/usage-error.ts +7 -0
- package/src/cli.ts +23 -1
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commands/token.ts +3 -1
- package/src/config/api-key-resolver.ts +12 -3
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/model-resolver.ts +21 -7
- package/src/config/models-config.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
- package/src/eval/__tests__/js-context-manager.test.ts +158 -1
- package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
- package/src/eval/__tests__/process-entry-import.test.ts +27 -0
- package/src/eval/agent-bridge.ts +121 -116
- package/src/eval/bridge-timeout.ts +20 -2
- package/src/eval/executor-base.ts +85 -7
- package/src/eval/jl/kernel.ts +2 -1
- package/src/eval/js/context-manager.ts +109 -32
- package/src/eval/js/process-entry.ts +27 -0
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/js/worker-core.ts +70 -9
- package/src/eval/js/worker-entry.ts +1 -1
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/spawn-options.ts +13 -0
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/eval/rb/kernel.ts +2 -1
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/memory-protocol.ts +13 -9
- package/src/lsp/client.ts +7 -1
- package/src/main.ts +29 -0
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/modes/components/chat-transcript-builder.ts +22 -1
- package/src/modes/components/status-line/component.ts +10 -1
- package/src/modes/components/transcript-container.ts +110 -7
- package/src/modes/controllers/command-controller.ts +12 -4
- package/src/modes/controllers/event-controller.ts +80 -15
- package/src/modes/controllers/selector-controller.ts +15 -3
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/rpc/host-tools.ts +15 -0
- package/src/modes/rpc/rpc-mode.ts +123 -48
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/utils/transcript-render-helpers.ts +54 -0
- package/src/modes/utils/ui-helpers.ts +27 -1
- package/src/modes/workflow.ts +6 -5
- package/src/prompts/advisor/system.md +1 -0
- package/src/sdk.ts +33 -3
- package/src/session/agent-session.ts +239 -17
- package/src/session/exit-diagnostics.ts +108 -0
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
- package/src/slash-commands/helpers/logout.ts +23 -3
- package/src/slash-commands/helpers/usage-report.ts +14 -2
- package/src/subprocess/worker-client.ts +9 -2
- package/src/task/executor.ts +8 -0
- package/src/task/render.test.ts +36 -0
- package/src/task/render.ts +55 -43
- package/src/tools/bash-skill-urls.ts +4 -1
- package/src/tools/bash.ts +1 -0
- package/src/tools/write.ts +82 -9
- package/src/tools/yield.ts +29 -1
- package/src/web/search/index.ts +39 -22
- package/src/web/search/provider.ts +33 -16
- package/src/web/search/providers/codex.ts +68 -21
package/src/tools/write.ts
CHANGED
|
@@ -83,27 +83,96 @@ const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
|
|
|
83
83
|
const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
|
|
84
84
|
|
|
85
85
|
const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
|
|
86
|
+
/**
|
|
87
|
+
* The head of a per-id directive line — `<id>:` / `<id>=` (optionally `#`-prefixed),
|
|
88
|
+
* regardless of whether its value is a valid `@side` token. Used only to sharpen the
|
|
89
|
+
* error message when a directive block is malformed (e.g. `15: some literal text`).
|
|
90
|
+
*/
|
|
91
|
+
const BULK_DIRECTIVE_HEAD_RE = /^#?\d+\s*[:=]/;
|
|
92
|
+
|
|
93
|
+
function truncateDirectiveLine(line: string): string {
|
|
94
|
+
return line.length > 60 ? `${line.slice(0, 57)}…` : line;
|
|
95
|
+
}
|
|
86
96
|
|
|
87
97
|
/**
|
|
88
98
|
* Parse `conflict://*` per-id directive content: every non-empty line must be
|
|
89
|
-
* `<id>: @side` (also accepted: `#<id> = @side`)
|
|
90
|
-
*
|
|
91
|
-
*
|
|
99
|
+
* `<id>: @side` (also accepted: `#<id> = @side`), where `@side` is one of
|
|
100
|
+
* `@ours` / `@theirs` / `@base` / `@both`.
|
|
101
|
+
*
|
|
102
|
+
* Returns `null` only when NO line is directive-shaped (→ uniform bulk mode).
|
|
103
|
+
* Throws on duplicate ids, and — critically — on a *partial* directive block:
|
|
104
|
+
* content that mixes valid `<id>: @side` lines with lines that aren't. Without
|
|
105
|
+
* that guard a per-id write carrying any non-token value (a literal or
|
|
106
|
+
* multi-line replacement, e.g. `15: <multi-line content>`) fell through to
|
|
107
|
+
* uniform bulk mode, which pasted the raw directive text verbatim into every
|
|
108
|
+
* block and still reported success. Per-id bulk is token-only; literal or
|
|
109
|
+
* multi-line replacements must go through individual `conflict://<N>` writes.
|
|
92
110
|
*/
|
|
93
111
|
function parseBulkDirectives(content: string): Map<number, string> | null {
|
|
94
112
|
const map = new Map<number, string>();
|
|
113
|
+
const stray: string[] = [];
|
|
114
|
+
let sawDirective = false;
|
|
95
115
|
for (const raw of content.split("\n")) {
|
|
96
116
|
const line = raw.trim();
|
|
97
117
|
if (line.length === 0) continue;
|
|
98
118
|
const match = line.match(BULK_DIRECTIVE_RE);
|
|
99
|
-
if (!match)
|
|
119
|
+
if (!match) {
|
|
120
|
+
stray.push(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
sawDirective = true;
|
|
100
124
|
const id = Number.parseInt(match[1], 10);
|
|
101
125
|
if (map.has(id)) {
|
|
102
126
|
throw new ToolError(`Bulk directive lists conflict #${id} twice — each id may appear once.`);
|
|
103
127
|
}
|
|
104
128
|
map.set(id, match[2]);
|
|
105
129
|
}
|
|
106
|
-
|
|
130
|
+
// No directive lines at all → not a per-id block; caller uses uniform mode.
|
|
131
|
+
if (!sawDirective) return null;
|
|
132
|
+
if (stray.length > 0) {
|
|
133
|
+
const sample = stray[0]!;
|
|
134
|
+
const tokenHint = BULK_DIRECTIVE_HEAD_RE.test(sample)
|
|
135
|
+
? `Per-id bulk only accepts the tokens @ours/@theirs/@base/@both — one side per id, single line. `
|
|
136
|
+
: "";
|
|
137
|
+
throw new ToolError(
|
|
138
|
+
`Malformed \`conflict://*\` per-id block: ${stray.length} line(s) are not \`<id>: @side\` directives (first: \`${truncateDirectiveLine(sample)}\`). ` +
|
|
139
|
+
tokenHint +
|
|
140
|
+
`Literal or multi-line replacement content isn't supported in a per-id block — resolve those blocks with individual \`write({ path: "conflict://<N>", content })\` calls (you can issue several at once). ` +
|
|
141
|
+
`For a pure pick-a-side pass, make every non-empty line \`<id>: @ours\` (or @theirs/@base/@both).`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return map;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve per-id directives, preferring the pre-strip `raw` content and falling
|
|
149
|
+
* back to the hashline-stripped `stripped` content.
|
|
150
|
+
*
|
|
151
|
+
* Raw is preferred because the `<id>:` directive heads look exactly like
|
|
152
|
+
* hashline `LINE:` prefixes and would be eaten by stripping. When the two
|
|
153
|
+
* contents are identical (hashline mode off) a single parse decides everything,
|
|
154
|
+
* so a malformed-block error propagates straight through — the previous
|
|
155
|
+
* `?? parseBulkDirectives(...)` chain would have swallowed it and silently
|
|
156
|
+
* degraded to uniform bulk mode, pasting the raw directive text into every
|
|
157
|
+
* block. When they differ, a malformed raw block still defers to a *clean*
|
|
158
|
+
* stripped block, but otherwise surfaces its error rather than degrading.
|
|
159
|
+
*/
|
|
160
|
+
function resolveBulkDirectives(raw: string, stripped: string): Map<number, string> | null {
|
|
161
|
+
if (raw === stripped) return parseBulkDirectives(raw);
|
|
162
|
+
let rawResult: Map<number, string> | null;
|
|
163
|
+
try {
|
|
164
|
+
rawResult = parseBulkDirectives(raw);
|
|
165
|
+
} catch (rawError) {
|
|
166
|
+
let fallback: Map<number, string> | null = null;
|
|
167
|
+
try {
|
|
168
|
+
fallback = parseBulkDirectives(stripped);
|
|
169
|
+
} catch {
|
|
170
|
+
fallback = null;
|
|
171
|
+
}
|
|
172
|
+
if (fallback) return fallback;
|
|
173
|
+
throw rawError;
|
|
174
|
+
}
|
|
175
|
+
return rawResult ?? parseBulkDirectives(stripped);
|
|
107
176
|
}
|
|
108
177
|
|
|
109
178
|
const writeSchema = type({
|
|
@@ -716,7 +785,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
716
785
|
// winner — one call instead of one write per conflict. Parsed from the
|
|
717
786
|
// PRE-strip content: hashline prefix stripping would otherwise eat the
|
|
718
787
|
// `<id>: ` heads as echoed line numbers.
|
|
719
|
-
const directives =
|
|
788
|
+
const directives = resolveBulkDirectives(rawContent, replacementContent);
|
|
720
789
|
if (directives) {
|
|
721
790
|
const known = new Set(allEntries.map(entry => entry.id));
|
|
722
791
|
const unknown = [...directives.keys()].filter(id => !known.has(id));
|
|
@@ -898,9 +967,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
898
967
|
}
|
|
899
968
|
return { content: [{ type: "text", text: resultText }], details: {} };
|
|
900
969
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
970
|
+
if (scheme !== "local") {
|
|
971
|
+
throw new ToolError(
|
|
972
|
+
`${scheme}:// URLs are read-only for write; use the protocol-specific tool for mutations.`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
// local:// is backed by the session-local artifact sandbox and is
|
|
976
|
+
// resolved by resolvePlanPath below so write/read share the same root.
|
|
904
977
|
}
|
|
905
978
|
|
|
906
979
|
const conflictUri = parseConflictUri(path);
|
package/src/tools/yield.ts
CHANGED
|
@@ -198,6 +198,14 @@ function wrapYieldParameters(dataSchema: Record<string, unknown>): Record<string
|
|
|
198
198
|
*/
|
|
199
199
|
const MAX_SCHEMA_RETRIES = 3;
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Max consecutive untyped empty-result submissions before the yield tool fails
|
|
203
|
+
* the child explicitly. Some weak tool callers can acknowledge the required
|
|
204
|
+
* wrapper in prose while repeatedly sending `{ result: {} }`; without a hard
|
|
205
|
+
* stop the parent waits forever.
|
|
206
|
+
*/
|
|
207
|
+
const MAX_EMPTY_RESULT_RETRIES = 3;
|
|
208
|
+
|
|
201
209
|
export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
202
210
|
readonly name = "yield";
|
|
203
211
|
readonly approval = "read" as const;
|
|
@@ -217,6 +225,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
|
217
225
|
#knownSectionLabels: readonly string[] = [];
|
|
218
226
|
#isKnownSection?: (label: string) => boolean;
|
|
219
227
|
#schemaValidationFailures = 0;
|
|
228
|
+
#emptyResultFailures = 0;
|
|
220
229
|
|
|
221
230
|
constructor(session: ToolSession) {
|
|
222
231
|
let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
|
|
@@ -322,8 +331,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
|
322
331
|
throw new Error("result cannot contain both data and error");
|
|
323
332
|
}
|
|
324
333
|
if (errorMessage === undefined && data === undefined && yieldType === undefined) {
|
|
334
|
+
this.#emptyResultFailures++;
|
|
335
|
+
if (this.#emptyResultFailures > MAX_EMPTY_RESULT_RETRIES) {
|
|
336
|
+
const attemptCount = this.#emptyResultFailures;
|
|
337
|
+
this.#emptyResultFailures = 0;
|
|
338
|
+
const error =
|
|
339
|
+
`yield result stayed empty after ${attemptCount} consecutive attempt(s); aborting child instead of retrying forever. ` +
|
|
340
|
+
'Submit success as `{ "result": { "data": <your output> } }` or failure as `{ "result": { "error": "message" } }`.';
|
|
341
|
+
return {
|
|
342
|
+
content: [{ type: "text", text: `Task aborted: ${error}` }],
|
|
343
|
+
details: {
|
|
344
|
+
data: undefined,
|
|
345
|
+
status: "aborted",
|
|
346
|
+
error,
|
|
347
|
+
type: yieldType,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const remaining = MAX_EMPTY_RESULT_RETRIES - this.#emptyResultFailures;
|
|
325
352
|
throw new Error(
|
|
326
|
-
|
|
353
|
+
`result must contain either \`data\` or \`error\`. Use \`{result: {data: <your output>}}\` for success or \`{result: {error: "message"}}\` for failure. Empty untyped result retries remaining before abort: ${remaining}.`,
|
|
327
354
|
);
|
|
328
355
|
}
|
|
329
356
|
|
|
@@ -370,6 +397,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
|
370
397
|
}
|
|
371
398
|
}
|
|
372
399
|
|
|
400
|
+
this.#emptyResultFailures = 0;
|
|
373
401
|
const responseText =
|
|
374
402
|
status === "aborted"
|
|
375
403
|
? `Task aborted: ${errorMessage}`
|
package/src/web/search/index.ts
CHANGED
|
@@ -21,8 +21,10 @@ import {
|
|
|
21
21
|
formatSearchProviderFailure,
|
|
22
22
|
formatSearchProviderFailures,
|
|
23
23
|
getSearchProvider,
|
|
24
|
-
|
|
24
|
+
getSearchProviderLabel,
|
|
25
|
+
resolveProviderCandidates,
|
|
25
26
|
type SearchProvider,
|
|
27
|
+
type SearchProviderCandidate,
|
|
26
28
|
} from "./provider";
|
|
27
29
|
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
|
|
28
30
|
import type { SearchProviderId, SearchResponse } from "./types";
|
|
@@ -128,25 +130,18 @@ async function executeSearch(
|
|
|
128
130
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
|
|
129
131
|
const { authStorage, sessionId, signal } = options;
|
|
130
132
|
const explicitProvider = params.provider;
|
|
131
|
-
let
|
|
133
|
+
let candidates: SearchProviderCandidate[];
|
|
132
134
|
if (explicitProvider && explicitProvider !== "auto") {
|
|
133
135
|
const provider = await getSearchProvider(explicitProvider);
|
|
134
|
-
|
|
135
|
-
? [
|
|
136
|
-
:
|
|
136
|
+
candidates = (await provider.isExplicitlyAvailable(authStorage))
|
|
137
|
+
? [{ id: explicitProvider, explicit: true }]
|
|
138
|
+
: resolveProviderCandidates("auto");
|
|
137
139
|
} else if (explicitProvider === "auto") {
|
|
138
140
|
// Explicit `--provider auto` bypasses the configured preferred provider
|
|
139
141
|
// for this invocation; exclusions still apply.
|
|
140
|
-
|
|
142
|
+
candidates = resolveProviderCandidates("auto");
|
|
141
143
|
} else {
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
if (providers.length === 0) {
|
|
145
|
-
const message = "No web search provider configured.";
|
|
146
|
-
return {
|
|
147
|
-
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
148
|
-
details: { response: { provider: "none", sources: [] }, error: message },
|
|
149
|
-
};
|
|
144
|
+
candidates = resolveProviderCandidates();
|
|
150
145
|
}
|
|
151
146
|
|
|
152
147
|
// Invariant across providers; read once and tolerate an uninitialized
|
|
@@ -166,11 +161,22 @@ async function executeSearch(
|
|
|
166
161
|
geminiModel = undefined;
|
|
167
162
|
}
|
|
168
163
|
|
|
169
|
-
const failures: Array<{ provider: SearchProvider
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
164
|
+
const failures: Array<{ provider: Pick<SearchProvider, "id" | "label">; error: unknown }> = [];
|
|
165
|
+
let availableProviderCount = 0;
|
|
166
|
+
let lastProvider: Pick<SearchProvider, "id" | "label"> | undefined;
|
|
167
|
+
for (const candidate of candidates) {
|
|
168
|
+
let provider: SearchProvider | undefined;
|
|
169
|
+
const providerMeta = { id: candidate.id, label: getSearchProviderLabel(candidate.id) };
|
|
170
|
+
lastProvider = providerMeta;
|
|
173
171
|
try {
|
|
172
|
+
provider = await getSearchProvider(candidate.id);
|
|
173
|
+
const available = candidate.explicit
|
|
174
|
+
? await provider.isExplicitlyAvailable(authStorage)
|
|
175
|
+
: await provider.isAvailable(authStorage);
|
|
176
|
+
if (!available) continue;
|
|
177
|
+
availableProviderCount++;
|
|
178
|
+
lastProvider = provider;
|
|
179
|
+
|
|
174
180
|
const response = await provider.search({
|
|
175
181
|
query: params.query,
|
|
176
182
|
limit: params.limit,
|
|
@@ -203,20 +209,31 @@ async function executeSearch(
|
|
|
203
209
|
// failure and the loop falls through to the next provider (or to the
|
|
204
210
|
// summary error), masking the cancellation.
|
|
205
211
|
throwIfAborted(signal);
|
|
206
|
-
failures.push({ provider, error });
|
|
212
|
+
failures.push({ provider: provider ?? providerMeta, error });
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
|
|
216
|
+
if (availableProviderCount === 0 && failures.length === 0) {
|
|
217
|
+
const message = "No web search provider configured.";
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
220
|
+
details: { response: { provider: "none", sources: [] }, error: message },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
210
224
|
const lastFailure = failures[failures.length - 1];
|
|
211
225
|
const baseMessage = lastFailure
|
|
212
226
|
? formatSearchProviderFailure(lastFailure.error, lastFailure.provider)
|
|
213
|
-
: `Unknown error from ${lastProvider
|
|
227
|
+
: `Unknown error from ${lastProvider?.label ?? "web search provider"}`;
|
|
214
228
|
const message =
|
|
215
|
-
|
|
229
|
+
failures.length > 1 ? `All web search providers failed: ${formatSearchProviderFailures(failures)}` : baseMessage;
|
|
216
230
|
|
|
217
231
|
return {
|
|
218
232
|
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
219
|
-
details: {
|
|
233
|
+
details: {
|
|
234
|
+
response: { provider: lastFailure?.provider.id ?? lastProvider?.id ?? "none", sources: [] },
|
|
235
|
+
error: message,
|
|
236
|
+
},
|
|
220
237
|
};
|
|
221
238
|
}
|
|
222
239
|
|
|
@@ -210,10 +210,34 @@ export function isSearchProviderExcluded(id: SearchProviderId): boolean {
|
|
|
210
210
|
return excludedProvIds.has(id);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
export interface SearchProviderCandidate {
|
|
214
|
+
id: SearchProviderId;
|
|
215
|
+
explicit: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Return provider candidates in fallback order without loading their modules. */
|
|
219
|
+
export function resolveProviderCandidates(
|
|
220
|
+
preferredProvider: SearchProviderId | "auto" = preferredProvId,
|
|
221
|
+
): SearchProviderCandidate[] {
|
|
222
|
+
const candidates: SearchProviderCandidate[] = [];
|
|
223
|
+
|
|
224
|
+
if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
|
|
225
|
+
candidates.push({ id: preferredProvider, explicit: true });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const id of SEARCH_PROVIDER_ORDER) {
|
|
229
|
+
if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
|
|
230
|
+
candidates.push({ id, explicit: false });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return candidates;
|
|
234
|
+
}
|
|
235
|
+
|
|
213
236
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
237
|
+
* Resolve the complete available provider chain.
|
|
238
|
+
*
|
|
239
|
+
* This compatibility helper loads every candidate. Search execution should use
|
|
240
|
+
* {@link resolveProviderCandidates} so fallback modules load only when reached.
|
|
217
241
|
*/
|
|
218
242
|
export async function resolveProviderChain(
|
|
219
243
|
authStorage: AuthStorage,
|
|
@@ -221,19 +245,12 @@ export async function resolveProviderChain(
|
|
|
221
245
|
): Promise<SearchProvider[]> {
|
|
222
246
|
const providers: SearchProvider[] = [];
|
|
223
247
|
|
|
224
|
-
|
|
225
|
-
const provider = await getSearchProvider(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
for (const id of SEARCH_PROVIDER_ORDER) {
|
|
232
|
-
if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
|
|
233
|
-
const provider = await getSearchProvider(id);
|
|
234
|
-
if (await provider.isAvailable(authStorage)) {
|
|
235
|
-
providers.push(provider);
|
|
236
|
-
}
|
|
248
|
+
for (const candidate of resolveProviderCandidates(preferredProvider)) {
|
|
249
|
+
const provider = await getSearchProvider(candidate.id);
|
|
250
|
+
const available = candidate.explicit
|
|
251
|
+
? await provider.isExplicitlyAvailable(authStorage)
|
|
252
|
+
: await provider.isAvailable(authStorage);
|
|
253
|
+
if (available) providers.push(provider);
|
|
237
254
|
}
|
|
238
255
|
|
|
239
256
|
return providers;
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
|
|
8
8
|
*/
|
|
9
9
|
import * as os from "node:os";
|
|
10
|
-
import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
|
|
10
|
+
import { type AuthStorage, type FetchImpl, type Model, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
|
|
12
|
+
import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
|
|
13
|
+
import { createOpenAICodexCompatibilityMetadata } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
12
14
|
import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
|
|
15
|
+
import { CODEX_CLIENT_VERSION, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "@oh-my-pi/pi-catalog/wire/codex";
|
|
13
16
|
import { $env, readSseJson } from "@oh-my-pi/pi-utils";
|
|
14
17
|
import packageJson from "../../../../package.json" with { type: "json" };
|
|
15
18
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
@@ -22,6 +25,9 @@ const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
|
22
25
|
const CODEX_RESPONSES_PATH = "/codex/responses";
|
|
23
26
|
const FALLBACK_MODEL = "gpt-5.5";
|
|
24
27
|
const DEFAULT_MODEL_PREFERENCES = [
|
|
28
|
+
"gpt-5.6-luna",
|
|
29
|
+
"gpt-5.6-terra",
|
|
30
|
+
"gpt-5.6-sol",
|
|
25
31
|
"gpt-5.5",
|
|
26
32
|
"gpt-5.4",
|
|
27
33
|
"gpt-5-codex",
|
|
@@ -35,15 +41,38 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
|
35
41
|
const DEFAULT_INSTRUCTIONS =
|
|
36
42
|
"You are a helpful assistant with web search capabilities. Search the web to answer the user's question accurately and cite your sources.";
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
type CodexSearchModel = Model<"openai-codex-responses">;
|
|
45
|
+
|
|
46
|
+
interface CodexModelCandidate {
|
|
47
|
+
modelId: string;
|
|
48
|
+
catalogModel?: CodexSearchModel;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getBundledCodexModels(): CodexSearchModel[] {
|
|
52
|
+
const models: CodexSearchModel[] = [];
|
|
53
|
+
for (const model of getBundledModels("openai-codex")) {
|
|
54
|
+
if (model.api === "openai-codex-responses") {
|
|
55
|
+
models.push(model as CodexSearchModel);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return models;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getConfiguredModel(): CodexModelCandidate | undefined {
|
|
39
62
|
const configuredModel = $env.PI_CODEX_WEB_SEARCH_MODEL?.trim();
|
|
40
|
-
|
|
63
|
+
if (!configuredModel) return undefined;
|
|
64
|
+
|
|
65
|
+
const catalogModel = getBundledCodexModels().find(model => model.id === configuredModel);
|
|
66
|
+
return { modelId: configuredModel, ...(catalogModel ? { catalogModel } : {}) };
|
|
41
67
|
}
|
|
42
68
|
|
|
43
|
-
function getDefaultModelCandidates():
|
|
44
|
-
const bundledModels =
|
|
45
|
-
const
|
|
46
|
-
const
|
|
69
|
+
function getDefaultModelCandidates(): CodexModelCandidate[] {
|
|
70
|
+
const bundledModels = getBundledCodexModels();
|
|
71
|
+
const candidates: CodexModelCandidate[] = [];
|
|
72
|
+
for (const modelId of DEFAULT_MODEL_PREFERENCES) {
|
|
73
|
+
const catalogModel = bundledModels.find(model => model.id === modelId);
|
|
74
|
+
if (catalogModel) candidates.push({ modelId, catalogModel });
|
|
75
|
+
}
|
|
47
76
|
|
|
48
77
|
if (candidates.length > 0) {
|
|
49
78
|
return candidates;
|
|
@@ -51,10 +80,11 @@ function getDefaultModelCandidates(): string[] {
|
|
|
51
80
|
|
|
52
81
|
const nonMini = bundledModels.find(model => !model.id.includes("mini") && !model.id.includes("spark"));
|
|
53
82
|
if (nonMini) {
|
|
54
|
-
return [nonMini.id];
|
|
83
|
+
return [{ modelId: nonMini.id, catalogModel: nonMini }];
|
|
55
84
|
}
|
|
56
85
|
|
|
57
|
-
|
|
86
|
+
const fallbackModel = bundledModels[0];
|
|
87
|
+
return fallbackModel ? [{ modelId: fallbackModel.id, catalogModel: fallbackModel }] : [{ modelId: FALLBACK_MODEL }];
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
function shouldRetryWithNextDefaultModel(error: unknown): boolean {
|
|
@@ -301,9 +331,10 @@ async function findCodexAuth(
|
|
|
301
331
|
function buildCodexHeaders(accessToken: string, accountId: string): Record<string, string> {
|
|
302
332
|
return {
|
|
303
333
|
Authorization: `Bearer ${accessToken}`,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
334
|
+
[OPENAI_HEADERS.ACCOUNT_ID]: accountId,
|
|
335
|
+
[OPENAI_HEADERS.BETA]: OPENAI_HEADER_VALUES.BETA_RESPONSES,
|
|
336
|
+
[OPENAI_HEADERS.ORIGINATOR]: OPENAI_HEADER_VALUES.ORIGINATOR_CODEX,
|
|
337
|
+
[OPENAI_HEADERS.VERSION]: CODEX_CLIENT_VERSION,
|
|
307
338
|
"User-Agent": `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
|
308
339
|
Accept: "text/event-stream",
|
|
309
340
|
"Content-Type": "application/json",
|
|
@@ -323,7 +354,8 @@ async function callCodexSearch(
|
|
|
323
354
|
signal?: AbortSignal;
|
|
324
355
|
systemPrompt?: string;
|
|
325
356
|
searchContextSize?: "low" | "medium" | "high";
|
|
326
|
-
|
|
357
|
+
model: CodexModelCandidate;
|
|
358
|
+
sessionId?: string;
|
|
327
359
|
fetch?: FetchImpl;
|
|
328
360
|
},
|
|
329
361
|
): Promise<{
|
|
@@ -336,7 +368,8 @@ async function callCodexSearch(
|
|
|
336
368
|
const url = `${CODEX_BASE_URL}${CODEX_RESPONSES_PATH}`;
|
|
337
369
|
const headers = buildCodexHeaders(auth.accessToken, auth.accountId);
|
|
338
370
|
|
|
339
|
-
const requestedModel = options.modelId;
|
|
371
|
+
const requestedModel = options.model.modelId;
|
|
372
|
+
const usesResponsesLite = options.model.catalogModel?.useResponsesLite === true;
|
|
340
373
|
|
|
341
374
|
const body: Record<string, unknown> = {
|
|
342
375
|
model: requestedModel,
|
|
@@ -358,6 +391,18 @@ async function callCodexSearch(
|
|
|
358
391
|
tool_choice: { type: "web_search" },
|
|
359
392
|
instructions: options.systemPrompt ?? DEFAULT_INSTRUCTIONS,
|
|
360
393
|
};
|
|
394
|
+
if (usesResponsesLite) {
|
|
395
|
+
const metadata = createOpenAICodexCompatibilityMetadata({
|
|
396
|
+
sessionId: options.sessionId,
|
|
397
|
+
requestKind: "turn",
|
|
398
|
+
startNewTurn: true,
|
|
399
|
+
});
|
|
400
|
+
Object.assign(headers, metadata.headers);
|
|
401
|
+
headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
|
|
402
|
+
body.client_metadata = metadata.clientMetadata;
|
|
403
|
+
body.reasoning = { context: "all_turns" };
|
|
404
|
+
applyCodexResponsesLiteShape(body);
|
|
405
|
+
}
|
|
361
406
|
|
|
362
407
|
const fetchImpl = options.fetch ?? fetch;
|
|
363
408
|
const response = await fetchImpl(url, {
|
|
@@ -489,10 +534,11 @@ async function callCodexSearch(
|
|
|
489
534
|
* Default-model behavior:
|
|
490
535
|
* - If `PI_CODEX_WEB_SEARCH_MODEL` is set, use it exactly once and surface any
|
|
491
536
|
* upstream error verbatim.
|
|
492
|
-
* - Otherwise prefer ChatGPT-account-safe bundled defaults (GPT-5.
|
|
493
|
-
*
|
|
494
|
-
* known 400 "model is not supported" family. This avoids
|
|
495
|
-
* `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI
|
|
537
|
+
* - Otherwise prefer ChatGPT-account-safe bundled defaults (GPT-5.6 Luna,
|
|
538
|
+
* Terra, Sol, GPT-5.5, …) and retry the next candidate only when Codex
|
|
539
|
+
* returns the known 400 "model is not supported" family. This avoids
|
|
540
|
+
* selecting `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI
|
|
541
|
+
* rejects.
|
|
496
542
|
*/
|
|
497
543
|
export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
|
|
498
544
|
const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
|
|
@@ -520,15 +566,16 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
|
|
|
520
566
|
|
|
521
567
|
let lastError: unknown;
|
|
522
568
|
for (let index = 0; index < modelCandidates.length; index += 1) {
|
|
523
|
-
const
|
|
524
|
-
if (!
|
|
569
|
+
const candidate = modelCandidates[index];
|
|
570
|
+
if (!candidate) continue;
|
|
525
571
|
|
|
526
572
|
try {
|
|
527
573
|
return await callCodexSearch(auth, params.query, {
|
|
528
574
|
signal: params.signal,
|
|
529
575
|
systemPrompt: params.systemPrompt,
|
|
530
576
|
searchContextSize: "high",
|
|
531
|
-
|
|
577
|
+
model: candidate,
|
|
578
|
+
sessionId: params.sessionId,
|
|
532
579
|
fetch: params.fetch,
|
|
533
580
|
});
|
|
534
581
|
} catch (error) {
|