jeo-code 0.12.5 → 0.12.7
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 +21 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +6 -2
- package/src/agent/tools.ts +1 -0
- package/src/ai/model-cache.ts +16 -8
- package/src/ai/model-catalog.ts +7 -2
- package/src/ai/model-discovery.ts +13 -5
- package/src/ai/model-manager.ts +48 -2
- package/src/ai/providers/anthropic.ts +1 -0
- package/src/ai/providers/openai-responses.ts +1 -0
- package/src/commands/computer.ts +46 -15
- package/src/util/provider-error.ts +34 -3
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,25 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.12.7] - 2026-10-18
|
|
13
|
+
_Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization._
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Screenshot vision feedback** — carry PNG attachments through tool execution, batches, and native Anthropic/OpenAI Responses replay instead of spilling base64 as text. Preserve CLI screenshot base64 stdout.
|
|
17
|
+
- **Desktop input** — use Quartz scroll events on macOS (cliclick `w:` waits rather than scrolling), release the mouse after dragging on macOS/Linux, normalize macOS key aliases/modifier combinations, and terminate Linux text options before user text.
|
|
18
|
+
- **Execution reliability** — drain subprocess pipes concurrently with exit, use collision-resistant screenshot paths, reject non-finite numeric inputs and out-of-range waits/scrolls, and explicitly reject unsupported platforms. Existing enablement/supervisor gates remain unchanged.
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
- Added [desktop setup and troubleshooting](docs/computer-use.md), including platform requirements, OS permissions, coordinate caveats, and supported action semantics.
|
|
22
|
+
|
|
23
|
+
## [0.12.6] - 2026-10-17
|
|
24
|
+
_Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls._
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **OpenAI subscription startup behind a configured proxy** — account-confirmed Codex models keep their OAuth transport instead of sending a keyless request to the OpenAI-compatible endpoint. Cold calls confirm the subscription catalog first; explicit per-call URLs, configured proxy keys, and endpoint-discovered models retain their existing routes. Subscription requests use the direct Codex backend, not the configured proxy.
|
|
28
|
+
- **Provider-specific authentication recovery** — HTTP 401/403 errors name the appropriate login or API-key configuration, omit unrelated Antigravity advice for OpenAI, preserve exact status codes, and no longer treat a structured non-authentication failure as a credential rejection merely because its body mentions 401.
|
|
29
|
+
- **Account-bound subscription catalogs** — distinguish opaque bearer identities without exposing tokens, reject discovery results that finish after an account switch, share concurrent same-account catalog requests, and keep cancellation of one caller from interrupting other callers.
|
|
30
|
+
|
|
12
31
|
## [0.12.5] - 2026-10-17
|
|
13
32
|
_Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing._
|
|
14
33
|
|
|
@@ -2579,7 +2598,8 @@ _Initial release._
|
|
|
2579
2598
|
### Added
|
|
2580
2599
|
- Initial jeo-code agent and CLI.
|
|
2581
2600
|
|
|
2582
|
-
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.
|
|
2601
|
+
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.6...HEAD
|
|
2602
|
+
[0.12.6]: https://github.com/akillness/jeo-code/releases/tag/v0.12.6
|
|
2583
2603
|
[0.11.4]: https://github.com/akillness/jeo-code/releases/tag/v0.11.4
|
|
2584
2604
|
[0.11.3]: https://github.com/akillness/jeo-code/releases/tag/v0.11.3
|
|
2585
2605
|
[0.11.2]: https://github.com/akillness/jeo-code/releases/tag/v0.11.2
|
package/README.ja.md
CHANGED
|
@@ -416,10 +416,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
416
416
|
|
|
417
417
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
418
418
|
- **[Unreleased]**
|
|
419
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
420
|
+
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
419
421
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
420
422
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
421
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
422
|
-
- **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
|
|
423
423
|
|
|
424
424
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
425
425
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -417,10 +417,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
417
417
|
|
|
418
418
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
419
419
|
- **[Unreleased]**
|
|
420
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
421
|
+
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
420
422
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
421
423
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
422
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
423
|
-
- **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
|
|
424
424
|
|
|
425
425
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
426
426
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -409,10 +409,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
409
409
|
|
|
410
410
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
411
411
|
- **[Unreleased]**
|
|
412
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
413
|
+
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
412
414
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
413
415
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
414
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
415
|
-
- **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
|
|
416
416
|
|
|
417
417
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
418
418
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -414,10 +414,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
414
414
|
|
|
415
415
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
416
416
|
- **[Unreleased]**
|
|
417
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
418
|
+
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
417
419
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
418
420
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
419
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
420
|
-
- **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
|
|
421
421
|
|
|
422
422
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
423
423
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -1215,6 +1215,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1215
1215
|
const { tool, arguments: args } = call;
|
|
1216
1216
|
let success: boolean;
|
|
1217
1217
|
let output: string;
|
|
1218
|
+
let images: import("../ai/types").ImageAttachment[] | undefined;
|
|
1218
1219
|
|
|
1219
1220
|
if (tool === "done") {
|
|
1220
1221
|
success = false;
|
|
@@ -1242,6 +1243,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1242
1243
|
const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
|
|
1243
1244
|
const res = await handler(args ?? {}, cwd, onProgress);
|
|
1244
1245
|
success = res.success;
|
|
1246
|
+
images = res.images;
|
|
1245
1247
|
output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
|
|
1246
1248
|
} catch (err: any) {
|
|
1247
1249
|
success = false;
|
|
@@ -1250,7 +1252,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1250
1252
|
}
|
|
1251
1253
|
}
|
|
1252
1254
|
}
|
|
1253
|
-
return { success, output };
|
|
1255
|
+
return { success, output, images };
|
|
1254
1256
|
};
|
|
1255
1257
|
|
|
1256
1258
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
@@ -1293,7 +1295,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1293
1295
|
}
|
|
1294
1296
|
}
|
|
1295
1297
|
|
|
1296
|
-
const results: { success: boolean; output: string; executed: boolean }[] = Array.from(
|
|
1298
|
+
const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[] }[] = Array.from(
|
|
1297
1299
|
{ length: toolCalls.length },
|
|
1298
1300
|
() => ({ success: false, output: "", executed: false })
|
|
1299
1301
|
);
|
|
@@ -1385,6 +1387,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1385
1387
|
}));
|
|
1386
1388
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
|
|
1387
1389
|
const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
|
|
1390
|
+
const images = indices.flatMap(idx => results[idx].images ?? []);
|
|
1391
|
+
if (images.length) resultMsg.images = images;
|
|
1388
1392
|
if (hookExtra) resultMsg.toolResultExtra = hookExtra;
|
|
1389
1393
|
history.push(resultMsg);
|
|
1390
1394
|
};
|
package/src/agent/tools.ts
CHANGED
package/src/ai/model-cache.ts
CHANGED
|
@@ -60,6 +60,21 @@ function cachePath(): string {
|
|
|
60
60
|
return path.join(cacheDir(), "model-catalog-cache.json");
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Account-bound catalog identity; opaque manual bearers must not share a scope. */
|
|
64
|
+
export async function openAIOauthAccountScope(credential?: Credential): Promise<string | undefined> {
|
|
65
|
+
let selected = credential;
|
|
66
|
+
if (!selected) {
|
|
67
|
+
const snapshot = await snapshotProvider("openai");
|
|
68
|
+
if (!snapshot.oauth) return undefined;
|
|
69
|
+
selected = { kind: "oauth", provider: "openai", token: snapshot.oauth };
|
|
70
|
+
}
|
|
71
|
+
if (selected.kind !== "oauth" || selected.provider !== "openai") return undefined;
|
|
72
|
+
const stored = await getStoredOAuth("openai");
|
|
73
|
+
const accountId = extractChatgptAccountId(selected.token)
|
|
74
|
+
|| (stored?.access === selected.token ? stored.accountId?.trim() : undefined);
|
|
75
|
+
return accountId || `token:${createHash("sha256").update(selected.token).digest("hex")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
/** Peek at the selected OAuth account without importing or refreshing credentials. */
|
|
64
79
|
export async function antigravityAccountScope(credential?: Credential): Promise<string | undefined> {
|
|
65
80
|
try {
|
|
@@ -251,14 +266,7 @@ export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccou
|
|
|
251
266
|
/** Read + apply in one step. Returns the cache so callers can decide about refreshing. */
|
|
252
267
|
export async function rehydrateLiveModels(): Promise<ModelCacheFile | null> {
|
|
253
268
|
const cache = await readModelCache();
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
const stored = await getStoredOAuth("openai");
|
|
257
|
-
const jwtAccountId = stored ? extractChatgptAccountId(stored.access) : undefined;
|
|
258
|
-
accountId = jwtAccountId || stored?.accountId;
|
|
259
|
-
} catch {
|
|
260
|
-
accountId = undefined;
|
|
261
|
-
}
|
|
269
|
+
const accountId = await openAIOauthAccountScope();
|
|
262
270
|
const antigravityAccountId = await antigravityAccountScope();
|
|
263
271
|
setOpenAIOauthAccountScope(accountId);
|
|
264
272
|
setAntigravityAccountScope(antigravityAccountId);
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -255,12 +255,17 @@ export function recordLiveCodexModels(ids: readonly string[], accountId?: string
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/** A confirmed account catalog is authoritative; use the snapshot before discovery. */
|
|
258
|
-
export function isCodexModel(model: string): boolean {
|
|
258
|
+
export function isCodexModel(model: string, accountId = activeOpenAIOauthScope): boolean {
|
|
259
259
|
const wire = model.startsWith("openai/") ? model.slice(7) : model;
|
|
260
|
-
const confirmed = liveCodexModels.get(
|
|
260
|
+
const confirmed = liveCodexModels.get(oauthScope(accountId));
|
|
261
261
|
return confirmed ? confirmed.has(wire) : CODEX_MODELS.includes(wire);
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/** Whether discovery (including a restored account cache) established a Codex catalog. */
|
|
265
|
+
export function hasConfirmedCodexCatalog(accountId = activeOpenAIOauthScope): boolean {
|
|
266
|
+
return liveCodexModels.has(oauthScope(accountId));
|
|
267
|
+
}
|
|
268
|
+
|
|
264
269
|
/** Test-only reset for live Codex observations and the active account scope. */
|
|
265
270
|
export function resetLiveCodexModels(): void {
|
|
266
271
|
liveCodexModels.clear();
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { extractChatgptAccountId } from "./providers/openai-responses";
|
|
25
25
|
import { getStoredOAuth } from "../auth/storage";
|
|
26
26
|
import { openaiCompatDef } from "./providers/openai-compatible-catalog";
|
|
27
|
-
import { antigravityAccountScope } from "./model-cache";
|
|
27
|
+
import { antigravityAccountScope, openAIOauthAccountScope } from "./model-cache";
|
|
28
28
|
import { getAntigravityUserAgent, resolveAntigravityProjectId } from "./providers/antigravity";
|
|
29
29
|
import { discoverGoogleProjectId, ANTIGRAVITY_DISCOVERY_METADATA } from "../auth/flows/google-project";
|
|
30
30
|
import { getKimiCommonHeaders } from "../auth/flows/kimi";
|
|
@@ -412,6 +412,7 @@ export async function listProviderModels(
|
|
|
412
412
|
|
|
413
413
|
let cred: Credential | undefined;
|
|
414
414
|
let oauthAccountId: string | undefined;
|
|
415
|
+
let oauthCatalogScope: string | undefined;
|
|
415
416
|
let source: ProviderModelsResult["source"] = "keyless";
|
|
416
417
|
if (provider === "xai") {
|
|
417
418
|
// xAI (Grok) is API-key only and not an OAuth AuthProvider: resolve its key
|
|
@@ -466,8 +467,9 @@ export async function listProviderModels(
|
|
|
466
467
|
if (provider === "openai" && source === "oauth" && cred.kind === "oauth") {
|
|
467
468
|
const stored = await getStoredOAuth("openai").catch(() => undefined);
|
|
468
469
|
const jwtAccountId = extractChatgptAccountId(cred.token);
|
|
469
|
-
oauthAccountId = jwtAccountId || stored?.accountId;
|
|
470
|
-
|
|
470
|
+
oauthAccountId = jwtAccountId || (stored?.access === cred.token ? stored.accountId : undefined);
|
|
471
|
+
oauthCatalogScope = await openAIOauthAccountScope(cred);
|
|
472
|
+
setOpenAIOauthAccountScope(oauthCatalogScope);
|
|
471
473
|
}
|
|
472
474
|
// Gemini models are exposed ONLY under API-key auth: a gemini OAuth token can
|
|
473
475
|
// still list models from generativelanguage.googleapis.com, but it can no
|
|
@@ -484,7 +486,7 @@ export async function listProviderModels(
|
|
|
484
486
|
}
|
|
485
487
|
}
|
|
486
488
|
|
|
487
|
-
const scope = { ...(
|
|
489
|
+
const scope = { ...(oauthCatalogScope ? { accountId: oauthCatalogScope } : {}), ...(baseUrl ? { baseUrl } : {}) };
|
|
488
490
|
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
489
491
|
let signal = opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
490
492
|
let initialAntigravityScope: string | undefined;
|
|
@@ -578,6 +580,12 @@ export async function listProviderModels(
|
|
|
578
580
|
}
|
|
579
581
|
const allModels = [...new Set(ids)].sort();
|
|
580
582
|
const models = allModels.slice(0, limit);
|
|
583
|
+
if (provider === "openai" && source === "oauth" && !baseUrl) {
|
|
584
|
+
if (await openAIOauthAccountScope() !== oauthCatalogScope) {
|
|
585
|
+
return { provider, models: [], ok: false, source, error: "account changed during model discovery", ...scope };
|
|
586
|
+
}
|
|
587
|
+
setOpenAIOauthAccountScope(oauthCatalogScope);
|
|
588
|
+
}
|
|
581
589
|
if (provider === "antigravity") {
|
|
582
590
|
const currentScope = await antigravityAccountScope();
|
|
583
591
|
if (currentScope !== scope.accountId && currentScope !== initialAntigravityScope) {
|
|
@@ -594,7 +602,7 @@ export async function listProviderModels(
|
|
|
594
602
|
replace: provider === "openai" || provider === "antigravity",
|
|
595
603
|
...(source === "oauth" && scope.accountId ? { accountId: scope.accountId } : {}),
|
|
596
604
|
});
|
|
597
|
-
if (provider === "openai" && source === "oauth" && !baseUrl) recordLiveCodexModels(allModels,
|
|
605
|
+
if (provider === "openai" && source === "oauth" && !baseUrl) recordLiveCodexModels(allModels, oauthCatalogScope);
|
|
598
606
|
if (provider === "kimi" && source === "oauth") recordLiveKimiCodeModels(allModels);
|
|
599
607
|
return {
|
|
600
608
|
provider,
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -6,12 +6,15 @@ import "./register-providers"; // side-effect: registers built-in adapters into
|
|
|
6
6
|
import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
|
|
7
7
|
import { expandAlias, resolveModelId, effectiveAliasesFor, BUILTIN_ALIASES } from "./model-registry";
|
|
8
8
|
import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
|
|
9
|
-
import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel, isKimiCodeModel, findLiveCatalogModel } from "./model-catalog";
|
|
9
|
+
import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel, isKimiCodeModel, findLiveCatalogModel, hasConfirmedCodexCatalog, isLiveProviderModel } from "./model-catalog";
|
|
10
10
|
import { xaiCredential } from "./providers/xai";
|
|
11
11
|
import { allCompatNames, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
|
|
12
12
|
import { withRetry, defaultRetryable, withConnectionContext, isConnectionError, ConnectionContextError, type RetryOptions } from "../util/retry";
|
|
13
13
|
import { jeoEnv } from "../util/env";
|
|
14
14
|
import type { Config } from "../agent/state";
|
|
15
|
+
import { listProviderModels } from "./model-discovery";
|
|
16
|
+
import type { ProviderModelsResult } from "./model-discovery";
|
|
17
|
+
import { openAIOauthAccountScope } from "./model-cache";
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
|
|
@@ -404,6 +407,8 @@ interface Resolved {
|
|
|
404
407
|
baseUrl: string | undefined;
|
|
405
408
|
}
|
|
406
409
|
|
|
410
|
+
const codexCatalogInFlight = new Map<string, Promise<ProviderModelsResult>>();
|
|
411
|
+
|
|
407
412
|
/**
|
|
408
413
|
* The credential to actually use for a provider call. A configured local OpenAI-compatible base
|
|
409
414
|
* URL must use the standard /chat/completions path, but the openai adapter dispatches on
|
|
@@ -436,7 +441,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
|
|
|
436
441
|
}
|
|
437
442
|
const adapter = providerRegistry.get(provider)!;
|
|
438
443
|
|
|
439
|
-
|
|
444
|
+
let baseUrl =
|
|
440
445
|
options.baseUrl ??
|
|
441
446
|
(provider === "openai" ? config.openaiBaseUrl : undefined) ??
|
|
442
447
|
(provider === "ollama" ? config.ollamaBaseUrl : undefined) ??
|
|
@@ -503,6 +508,47 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
|
|
|
503
508
|
const credentialProvider = provider as AuthProvider;
|
|
504
509
|
const credential = await resolveCredential(credentialProvider);
|
|
505
510
|
const effective = effectiveCredentialForProvider(credentialProvider, credential, config, model);
|
|
511
|
+
// A subscription catalog is independent of a configured OpenAI-compatible
|
|
512
|
+
// endpoint. Keep its OAuth transport rather than replacing it with "no-key".
|
|
513
|
+
// Explicit endpoint overrides, proxy keys and endpoint-discovered models win.
|
|
514
|
+
if (provider === "openai" && baseUrl && options.baseUrl === undefined
|
|
515
|
+
&& effective.kind === "oauth" && !config.providers.openai
|
|
516
|
+
&& !isLiveProviderModel("openai", model, { openaiBaseUrl: baseUrl })) {
|
|
517
|
+
options.signal?.throwIfAborted();
|
|
518
|
+
const accountScope = await openAIOauthAccountScope(effective);
|
|
519
|
+
if (accountScope && !hasConfirmedCodexCatalog(accountScope)) {
|
|
520
|
+
let discovery = codexCatalogInFlight.get(accountScope);
|
|
521
|
+
if (!discovery) {
|
|
522
|
+
// One caller's cancellation must not cancel another agent's catalog read.
|
|
523
|
+
discovery = listProviderModels("openai", { config, codexCatalog: true });
|
|
524
|
+
codexCatalogInFlight.set(accountScope, discovery);
|
|
525
|
+
void discovery.finally(() => codexCatalogInFlight.delete(accountScope)).catch(() => {});
|
|
526
|
+
}
|
|
527
|
+
const signal = options.signal;
|
|
528
|
+
if (signal) {
|
|
529
|
+
const aborted = Promise.withResolvers<never>();
|
|
530
|
+
const onAbort = () => aborted.reject(signal.reason);
|
|
531
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
532
|
+
try {
|
|
533
|
+
signal.throwIfAborted();
|
|
534
|
+
await Promise.race([discovery, aborted.promise]);
|
|
535
|
+
} finally {
|
|
536
|
+
signal.removeEventListener("abort", onAbort);
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
await discovery;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
options.signal?.throwIfAborted();
|
|
543
|
+
if (await openAIOauthAccountScope() !== accountScope) {
|
|
544
|
+
throw new Error("OpenAI credential changed during model discovery; retry the request.");
|
|
545
|
+
}
|
|
546
|
+
// Never infer cloud eligibility from the bundled snapshot when discovery fails.
|
|
547
|
+
if (accountScope && hasConfirmedCodexCatalog(accountScope) && isCodexModel(model, accountScope)) {
|
|
548
|
+
baseUrl = undefined;
|
|
549
|
+
callOptions.baseUrl = undefined;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
506
552
|
const isLocalOpenAi = provider === "openai" && !!baseUrl;
|
|
507
553
|
if (provider === "openai" && effective.kind === "oauth" && !isLocalOpenAi && !oauthServesModel("openai", model)) {
|
|
508
554
|
throw new Error(
|
|
@@ -240,6 +240,7 @@ export function buildAnthropicMessages(messages: Message[], model: string, think
|
|
|
240
240
|
const blocks: AnthropicContentBlock[] = m.toolResults.map(tr => ({
|
|
241
241
|
type: "tool_result", tool_use_id: tr.id, content: tr.output, is_error: tr.isError,
|
|
242
242
|
}));
|
|
243
|
+
for (const img of m.images ?? []) blocks.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data } });
|
|
243
244
|
if (m.toolResultExtra) blocks.push({ type: "text", text: m.toolResultExtra });
|
|
244
245
|
return { role: "user", content: blocks };
|
|
245
246
|
}
|
|
@@ -87,6 +87,7 @@ export function buildResponsesInput(messages: Message[], modelKey: string, strip
|
|
|
87
87
|
&& nonSystem[i - 1].role === "assistant" && responsesNativizable(nonSystem[i - 1], modelKey)) {
|
|
88
88
|
for (const tr of m.toolResults) items.push({ type: "function_call_output", call_id: tr.id, output: tr.output });
|
|
89
89
|
if (m.toolResultExtra) items.push({ role: "user", content: [{ type: "input_text", text: m.toolResultExtra }] });
|
|
90
|
+
if (m.images?.length) items.push({ role: "user", content: m.images.map(img => ({ type: "input_image", image_url: `data:${img.mediaType};base64,${img.data}` })) });
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
items.push(plain(m));
|
package/src/commands/computer.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
5
|
+
import type { ImageAttachment } from "../ai/types";
|
|
5
6
|
import { readGlobalConfig } from "../agent/state";
|
|
6
7
|
|
|
7
8
|
export interface ComputerAction {
|
|
@@ -36,9 +37,11 @@ async function logAudit(action: ComputerAction, success: boolean, error?: string
|
|
|
36
37
|
async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: string; stderr: string }> {
|
|
37
38
|
try {
|
|
38
39
|
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const [stdout, stderr] = await Promise.all([
|
|
41
|
+
new Response(proc.stdout).text(),
|
|
42
|
+
new Response(proc.stderr).text(),
|
|
43
|
+
proc.exited,
|
|
44
|
+
]);
|
|
42
45
|
return {
|
|
43
46
|
success: proc.exitCode === 0,
|
|
44
47
|
stdout,
|
|
@@ -53,7 +56,7 @@ async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: st
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
export async function executeComputerAction(action: ComputerAction, opts?: { enabledOverride?: boolean }): Promise<{ success: boolean; output: string; error?: string }> {
|
|
59
|
+
export async function executeComputerAction(action: ComputerAction, opts?: { enabledOverride?: boolean }): Promise<{ success: boolean; output: string; error?: string; images?: ImageAttachment[] }> {
|
|
57
60
|
let enabled = opts?.enabledOverride;
|
|
58
61
|
if (enabled === undefined) {
|
|
59
62
|
const config = await readGlobalConfig().catch(() => null);
|
|
@@ -68,6 +71,18 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
|
|
74
|
+
if (!action || typeof action !== "object") return { success: false, output: "", error: "Expected a computer action object." };
|
|
75
|
+
for (const field of ["x", "y", "deltaX", "deltaY", "duration"] as const) {
|
|
76
|
+
if (action[field] !== undefined && (typeof action[field] !== "number" || !Number.isFinite(action[field]))) {
|
|
77
|
+
return { success: false, output: "", error: `Invalid finite number for ${field}.` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (action.duration !== undefined && (action.duration < 0 || action.duration > 60)) {
|
|
81
|
+
return { success: false, output: "", error: "duration must be between 0 and 60 seconds." };
|
|
82
|
+
}
|
|
83
|
+
if (os.platform() !== "darwin" && os.platform() !== "linux") {
|
|
84
|
+
return { success: false, output: "", error: "Computer use supports macOS and Linux/X11 only." };
|
|
85
|
+
}
|
|
71
86
|
// Read-only actions don't need supervisor inputAllowed check
|
|
72
87
|
const isReadOnly = action.action === "screenshot" || action.action === "wait";
|
|
73
88
|
if (!isReadOnly) {
|
|
@@ -83,7 +98,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
83
98
|
switch (action.action) {
|
|
84
99
|
case "screenshot": {
|
|
85
100
|
const tmpDir = os.tmpdir();
|
|
86
|
-
const screenPath = path.join(tmpDir, `jeo-screen-${
|
|
101
|
+
const screenPath = path.join(tmpDir, `jeo-screen-${crypto.randomUUID()}.png`);
|
|
87
102
|
let cmd: string[];
|
|
88
103
|
if (isMac) {
|
|
89
104
|
cmd = ["screencapture", "-x", screenPath];
|
|
@@ -100,7 +115,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
100
115
|
const base64 = bytes.toString("base64");
|
|
101
116
|
await fs.unlink(screenPath).catch(() => {});
|
|
102
117
|
await logAudit(action, true);
|
|
103
|
-
return { success: true, output: base64 };
|
|
118
|
+
return { success: true, output: "Screenshot captured.", images: [{ mediaType: "image/png", data: base64 }] };
|
|
104
119
|
} catch (err: any) {
|
|
105
120
|
await logAudit(action, false, err.message);
|
|
106
121
|
return { success: false, output: "", error: `Failed to read screenshot file: ${err.message}` };
|
|
@@ -164,9 +179,9 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
164
179
|
}
|
|
165
180
|
let cmd: string[];
|
|
166
181
|
if (isMac) {
|
|
167
|
-
cmd = ["cliclick", `
|
|
182
|
+
cmd = ["cliclick", "dd:.", `dm:${action.x},${action.y}`, `du:${action.x},${action.y}`];
|
|
168
183
|
} else {
|
|
169
|
-
cmd = ["xdotool", "mousemove", String(action.x), String(action.y), "
|
|
184
|
+
cmd = ["xdotool", "mousedown", "1", "mousemove", String(action.x), String(action.y), "mouseup", "1"];
|
|
170
185
|
}
|
|
171
186
|
const res = await runCommand(cmd);
|
|
172
187
|
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
@@ -179,9 +194,14 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
179
194
|
let cmd: string[];
|
|
180
195
|
const dx = action.deltaX ?? 0;
|
|
181
196
|
const dy = action.deltaY ?? 0;
|
|
197
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy) || Math.abs(dx) > 1000 || Math.abs(dy) > 1000) {
|
|
198
|
+
return { success: false, output: "", error: "Scroll deltas must be integers between -1000 and 1000." };
|
|
199
|
+
}
|
|
200
|
+
if (dx === 0 && dy === 0) return { success: true, output: "Scroll skipped (zero deltas)." };
|
|
182
201
|
if (isMac) {
|
|
183
|
-
// cliclick scroll
|
|
184
|
-
cmd = ["
|
|
202
|
+
// cliclick's w: command WAITS; it cannot scroll. Use Quartz line events.
|
|
203
|
+
cmd = ["/usr/bin/osascript", "-l", "JavaScript", "-e",
|
|
204
|
+
`ObjC.import('Cocoa'); var event = $.CGEventCreateScrollWheelEvent(null, 1, 2, ${dy}, ${dx}); $.CGEventPost(0, event);`];
|
|
185
205
|
} else {
|
|
186
206
|
// xdotool click 4 (scroll up), 5 (scroll down), 6 (scroll left), 7 (scroll right)
|
|
187
207
|
const actions: string[] = [];
|
|
@@ -212,7 +232,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
212
232
|
if (isMac) {
|
|
213
233
|
cmd = ["cliclick", `t:${action.text}`];
|
|
214
234
|
} else {
|
|
215
|
-
cmd = ["xdotool", "type", action.text];
|
|
235
|
+
cmd = ["xdotool", "type", "--clearmodifiers", "--", action.text];
|
|
216
236
|
}
|
|
217
237
|
const res = await runCommand(cmd);
|
|
218
238
|
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
@@ -227,7 +247,16 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
227
247
|
}
|
|
228
248
|
let cmd: string[];
|
|
229
249
|
if (isMac) {
|
|
230
|
-
|
|
250
|
+
const aliases: Record<string, string> = { return: "return", enter: "return", escape: "esc", backspace: "delete", arrowup: "arrow-up", arrowdown: "arrow-down", arrowleft: "arrow-left", arrowright: "arrow-right", pageup: "page-up", pagedown: "page-down" };
|
|
251
|
+
const parts = action.key.toLowerCase().split("+");
|
|
252
|
+
const key = parts.pop()!;
|
|
253
|
+
const modifiers = parts.map(p => ({ command: "cmd", meta: "cmd", control: "ctrl", option: "alt" }[p] ?? p));
|
|
254
|
+
if (modifiers.some(p => !["cmd", "ctrl", "alt", "shift", "fn"].includes(p))) {
|
|
255
|
+
return { success: false, output: "", error: "Unknown key modifier." };
|
|
256
|
+
}
|
|
257
|
+
const press = key.length === 1 ? `t:${key}` : `kp:${aliases[key] ?? key}`;
|
|
258
|
+
cmd = ["cliclick", ...(modifiers.length ? [`kd:${modifiers.join(",")}`] : []), press,
|
|
259
|
+
...(modifiers.length ? [`ku:${modifiers.join(",")}`] : [])];
|
|
231
260
|
} else {
|
|
232
261
|
cmd = ["xdotool", "key", action.key];
|
|
233
262
|
}
|
|
@@ -250,6 +279,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
250
279
|
return { success: false, output: "", error: "Missing actions array for batch action." };
|
|
251
280
|
}
|
|
252
281
|
const results: string[] = [];
|
|
282
|
+
const images: ImageAttachment[] = [];
|
|
253
283
|
for (let i = 0; i < action.actions.length; i++) {
|
|
254
284
|
const subAction = action.actions[i];
|
|
255
285
|
const res = await executeComputerAction(subAction, opts);
|
|
@@ -257,12 +287,13 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
257
287
|
if (!res.success) {
|
|
258
288
|
const err = `Batch failed at step ${i}: ${res.error}`;
|
|
259
289
|
await logAudit(action, false, err);
|
|
260
|
-
return { success: false, output: results.join("\n"), error: err };
|
|
290
|
+
return { success: false, output: results.join("\n"), error: err, images };
|
|
261
291
|
}
|
|
262
292
|
results.push(`Step ${i}: ${res.output}`);
|
|
293
|
+
images.push(...(res.images ?? []));
|
|
263
294
|
}
|
|
264
295
|
await logAudit(action, true);
|
|
265
|
-
return { success: true, output: results.join("\n") };
|
|
296
|
+
return { success: true, output: results.join("\n"), images };
|
|
266
297
|
}
|
|
267
298
|
|
|
268
299
|
default:
|
|
@@ -329,7 +360,7 @@ export async function runComputerCommand(args: string[]): Promise<void> {
|
|
|
329
360
|
const res = await executeComputerAction(action);
|
|
330
361
|
if (res.success) {
|
|
331
362
|
if (actionName === "screenshot") {
|
|
332
|
-
console.log(res.output); // base64 output
|
|
363
|
+
console.log(res.images?.[0]?.data ?? res.output); // preserve CLI base64 output
|
|
333
364
|
} else {
|
|
334
365
|
console.log(res.output);
|
|
335
366
|
}
|
|
@@ -9,6 +9,8 @@ import { companyLabel } from "../ai/model-catalog";
|
|
|
9
9
|
import { ProviderHttpError, ProviderStreamError } from "../ai/providers/errors";
|
|
10
10
|
import { AgyCliError } from "../agent/antigravity-cli";
|
|
11
11
|
import { sanitizeForTerminal } from "../tui/components/code-view";
|
|
12
|
+
import { OAUTH_PROVIDERS, API_KEY_ONLY_PROVIDERS } from "../auth/storage";
|
|
13
|
+
import { openaiCompatDef, allCompatNames } from "../ai/providers/openai-compatible-catalog";
|
|
12
14
|
// Re-export: engine.ts and callers import the refusal predicate from here; the
|
|
13
15
|
// definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
|
|
14
16
|
export { isRefusalError } from "./retry";
|
|
@@ -72,6 +74,12 @@ function retryAfterMsOf(err: unknown): number | undefined {
|
|
|
72
74
|
return err instanceof ProviderHttpError ? err.retryAfterMs : undefined;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
function authProviderForLabel(label: string): string | undefined {
|
|
78
|
+
const normalized = label.toLowerCase() === "openai codex" ? "openai" : label.toLowerCase();
|
|
79
|
+
const matches = (id: string) => id === normalized || companyLabel(id).toLowerCase() === normalized;
|
|
80
|
+
return OAUTH_PROVIDERS.find(matches) ?? API_KEY_ONLY_PROVIDERS.find(matches) ?? allCompatNames().find(matches);
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
export function friendlyProviderError(err: unknown): string {
|
|
76
84
|
if (err instanceof AgyCliError) return sanitizeForTerminal(err.message);
|
|
77
85
|
if (err instanceof ConnectionContextError) {
|
|
@@ -92,7 +100,10 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
92
100
|
// Tencent/groq that speak Anthropic's/OpenAI's wire protocol — see
|
|
93
101
|
// `relabelProviderError`); only guess from message text for errors that never
|
|
94
102
|
// carried a `.provider` field (bare Error, a re-wrapped doneReason string, …).
|
|
95
|
-
const
|
|
103
|
+
const reportedProvider = knownErrorProvider(err) ?? /^(?:Error:\s*)?([^\r\n]+?) (?:request failed|stream failed|rejected the credential)\b/i.exec(msg)?.[1] ?? /^(?:Error:\s*)?(OpenAI(?: Codex)?|Antigravity|Anthropic|Gemini|Google)\b/i.exec(msg)?.[1];
|
|
104
|
+
// A raw prefix may contain a secret, not a provider name. Only registry labels
|
|
105
|
+
// may survive redaction or the round trip through an engine's doneReason.
|
|
106
|
+
const provider = reportedProvider && authProviderForLabel(reportedProvider) ? reportedProvider : (
|
|
96
107
|
/antigravity/i.test(msg)
|
|
97
108
|
? "Antigravity"
|
|
98
109
|
: /anthropic/i.test(msg)
|
|
@@ -117,8 +128,28 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
117
128
|
: "";
|
|
118
129
|
return `Rate limited by ${provider} (HTTP 429).${retry} Auto-retry cannot clear this window right now — slow your request rate, wait for the reset, or switch model with /model (a local ollama model never rate-limits).`;
|
|
119
130
|
}
|
|
120
|
-
|
|
121
|
-
|
|
131
|
+
const credentialStatus = status ?? Number(/\b(401|403)\b/.exec(msg)?.[1]);
|
|
132
|
+
if (credentialStatus === 401 || credentialStatus === 403) {
|
|
133
|
+
const authProvider = authProviderForLabel(provider);
|
|
134
|
+
let hint = "Check the configured credentials with 'jeo setup' or /provider.";
|
|
135
|
+
if (authProvider === "openai") {
|
|
136
|
+
hint = "For ChatGPT/Codex, run 'jeo auth login openai' or /provider login openai. For API/custom endpoints, check OPENAI_API_KEY and OPENAI_BASE_URL (config: providers.openai / openaiBaseUrl).";
|
|
137
|
+
} else if (authProvider === "gemini") {
|
|
138
|
+
hint = "Check GEMINI_API_KEY. For Google OAuth, select an antigravity/* model and run 'jeo auth login antigravity' or /provider login antigravity.";
|
|
139
|
+
} else if (authProvider === "antigravity") {
|
|
140
|
+
hint = "Run 'jeo auth login antigravity' or /provider login antigravity; a Gemini login only works when Cloud Code Assist authorizes that token.";
|
|
141
|
+
} else if (authProvider === "kimi") {
|
|
142
|
+
hint = "For Kimi Code, run 'jeo auth login kimi'. For the Moonshot API, check KIMI_API_KEY or set it with /provider key kimi <key>.";
|
|
143
|
+
} else if (authProvider && !OAUTH_PROVIDERS.some(id => id === authProvider)) {
|
|
144
|
+
const apiKeyEnv = openaiCompatDef(authProvider)?.apiKeyEnv ?? `${authProvider.toUpperCase()}_API_KEY`;
|
|
145
|
+
const command = API_KEY_ONLY_PROVIDERS.some(id => id === authProvider)
|
|
146
|
+
? ` Set the key with /provider key ${authProvider} <key> or 'jeo auth login ${authProvider} --token <key>'.`
|
|
147
|
+
: " Update the custom provider's configured credentials.";
|
|
148
|
+
hint = `Check ${apiKeyEnv} or providers.${authProvider}.${command}`;
|
|
149
|
+
} else if (authProvider) {
|
|
150
|
+
hint = `Run 'jeo auth login ${authProvider}' or /provider login ${authProvider}; for API keys, check providers.${authProvider}.`;
|
|
151
|
+
}
|
|
152
|
+
return `${provider} rejected the credential (HTTP ${credentialStatus}). Run 'jeo auth status'. ${hint}`;
|
|
122
153
|
}
|
|
123
154
|
// A persistent 5xx (server-side fault on the provider's backend) — `ProviderHttpError`
|
|
124
155
|
// already embeds the numeric status directly in its message ("… failed (HTTP 500) …",
|