@ggui-ai/ui-gen 0.9.0 → 0.11.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/dist/adapters/generation-dispatch.d.ts +9 -2
- package/dist/adapters/generation-dispatch.js +693 -89
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +28 -11
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +5 -3
- package/dist/advanced/index.js +714 -101
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +267 -3
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +14 -2
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js +11 -1
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-B9kV4U9h.d.ts → create-ui-generator-CF0EL0Li.d.ts} +13 -1
- package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
- package/dist/evaluation/axis-checks/index.d.ts +3 -3
- package/dist/evaluation/axis-checks/index.js +101 -0
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +1 -1
- package/dist/evaluation/axis-checks/registry.js +101 -0
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +46 -3
- package/dist/evaluation/index.js +358 -17
- package/dist/evaluation/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +2 -2
- package/dist/harness/check/runtime-render/index.js +268 -3
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.d.ts +2 -0
- package/dist/harness/check/runtime-render/render-check-worker.js +1332 -0
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -0
- package/dist/harness/index.d.ts +2 -2
- package/dist/harness/index.js +263 -18
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +1 -1
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.js +17 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +1 -1
- package/dist/harness/runtime.d.ts +9 -2
- package/dist/harness/runtime.js +20 -6
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +1 -1
- package/dist/harness/types-public.js +14 -2
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +708 -95
- package/dist/index.js.map +1 -1
- package/dist/{llm-router-1TyI0_TV.d.ts → llm-router-BEVtbAb9.d.ts} +61 -4
- package/dist/patch.d.ts +1 -1
- package/dist/provider-adapter.d.ts +16 -6
- package/dist/provider-adapter.js.map +1 -1
- package/dist/providers/index.d.ts +19 -6
- package/dist/providers/index.js.map +1 -1
- package/dist/{render-check-CNZjHD5g.d.ts → render-check-BZoIE8-u.d.ts} +21 -1
- package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
- package/dist/workflows.d.ts +1 -1
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +9 -0
- package/src/adapters/openrouter/client.ts +2 -2
- package/src/adapters/openrouter/types.ts +10 -0
- package/src/boilerplate/generate.ts +10 -2
- package/src/boilerplate/json-schema-ts.ts +22 -2
- package/src/boilerplate/system-prompt.ts +1 -0
- package/src/compile.ts +1 -1
- package/src/contract-context.ts +10 -2
- package/src/create-ui-generator.ts +14 -0
- package/src/design-system-docs.ts +6 -4
- package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/index.ts +11 -0
- package/src/evaluation/llm-evaluator.ts +14 -1
- package/src/evaluation/visual-evaluator.ts +44 -65
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/index.ts +8 -0
- package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
- package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
- package/src/harness/check/runtime-render/render-check-host.ts +201 -0
- package/src/harness/check/runtime-render/render-check-worker.ts +80 -0
- package/src/harness/check/runtime-render/render-check.ts +112 -1
- package/src/harness/coding/run-eval-round.ts +61 -5
- package/src/harness/index.ts +1 -1
- package/src/harness/llm-router.ts +397 -18
- package/src/harness/llm-trace-sink.ts +2 -1
- package/src/harness/runtime.ts +7 -0
- package/src/index.ts +5 -5
- package/src/provider-adapter.ts +16 -6
- package/src/providers/bedrock.ts +5 -4
- package/src/providers/index.ts +16 -4
- package/src/tools/render-check.ts +19 -7
|
@@ -121,6 +121,29 @@ export interface EvalRoundResult {
|
|
|
121
121
|
/** Cap feedback to N issues per eval-fix turn so the LLM stays surgical. */
|
|
122
122
|
const MAX_FEEDBACK_ISSUES = 3;
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Pick the ≤{@link MAX_FEEDBACK_ISSUES} issues an eval-fix turn feeds
|
|
126
|
+
* back. Provenance-aware (Exp 54 P1 addendum): ONE slot is reserved
|
|
127
|
+
* for the first deterministic tier-0 fail (axis/mode checks —
|
|
128
|
+
* machine-verified findings) when one exists; the rest fill
|
|
129
|
+
* fails-first then warns. Without the reservation, the merge order
|
|
130
|
+
* put deterministic findings last and any round with ≥3 LLM-judge
|
|
131
|
+
* fails silently starved them — the model never saw a precision-1.00
|
|
132
|
+
* structural finding while the stuck detector blamed it for not
|
|
133
|
+
* fixing it. Exactly one slot, so crash-class LLM fails keep the
|
|
134
|
+
* other two. Exported for the pins.
|
|
135
|
+
*/
|
|
136
|
+
export function selectActionableFeedback(issues: readonly EvalIssue[]): EvalIssue[] {
|
|
137
|
+
const pool = issues.filter((i) => i.result === "fail" || i.result === "warn");
|
|
138
|
+
const reserved = pool.find((i) => i.tier === 0 && i.result === "fail");
|
|
139
|
+
return [
|
|
140
|
+
...(reserved ? [reserved] : []),
|
|
141
|
+
...pool
|
|
142
|
+
.filter((i) => i !== reserved)
|
|
143
|
+
.sort((a, b) => (a.result === "fail" ? 0 : 1) - (b.result === "fail" ? 0 : 1)),
|
|
144
|
+
].slice(0, MAX_FEEDBACK_ISSUES);
|
|
145
|
+
}
|
|
146
|
+
|
|
124
147
|
/**
|
|
125
148
|
* Fingerprint a fail issue for cross-round stuck detection. Identifies the
|
|
126
149
|
* "same fail" without being thrown by minor description-text variation.
|
|
@@ -424,6 +447,13 @@ export async function runEvalRound(
|
|
|
424
447
|
// inside the same concurrent generation, so the race is
|
|
425
448
|
// real here too, not just on the coding turn).
|
|
426
449
|
routeOverride: evaluationAgent.routeOverride,
|
|
450
|
+
// #489 — thread the same provider-429-retry observer.
|
|
451
|
+
// Without this, a rate-limited retry inside the
|
|
452
|
+
// evaluation agent's apiCall() happens (the base-class
|
|
453
|
+
// retry loop is unconditional) but never reaches the
|
|
454
|
+
// caller, silently missing the host application's
|
|
455
|
+
// structured log.
|
|
456
|
+
onRetry: evaluationAgent.onRetry,
|
|
427
457
|
},
|
|
428
458
|
preWarmedContext,
|
|
429
459
|
)
|
|
@@ -437,6 +467,14 @@ export async function runEvalRound(
|
|
|
437
467
|
passThreshold: visualThreshold,
|
|
438
468
|
sampleProps: visualEvaluation?.sampleProps,
|
|
439
469
|
viewport: visualEvaluation?.viewport,
|
|
470
|
+
// #484/#489 — same threading as the LLM-eval leg above:
|
|
471
|
+
// the visual eval's multimodal call runs inside the same
|
|
472
|
+
// concurrent generation, so it needs the same routing
|
|
473
|
+
// override (never fall back to `process.env`) and the
|
|
474
|
+
// same provider-429-retry observer (retries reach the
|
|
475
|
+
// host application's structured log).
|
|
476
|
+
routeOverride: visualEvalAgent.routeOverride,
|
|
477
|
+
onRetry: visualEvalAgent.onRetry,
|
|
440
478
|
},
|
|
441
479
|
)
|
|
442
480
|
: null,
|
|
@@ -758,10 +796,19 @@ export async function runEvalRound(
|
|
|
758
796
|
// Feeding too many issues at once causes the LLM to make many simultaneous
|
|
759
797
|
// changes, breaking JSX nesting in complex components. Prioritize fails
|
|
760
798
|
// first, then warns, capped to keep fixes surgical.
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
799
|
+
//
|
|
800
|
+
// Provenance-aware selection (Exp 54 P1 addendum): deterministic
|
|
801
|
+
// tier-0 fails (axis/mode checks — machine-verified, precision-1.00
|
|
802
|
+
// findings) get ONE reserved slot, first. Before this, the merge
|
|
803
|
+
// order (`allIssues = [...llm, ...visual, ...mode]`) + stable sort
|
|
804
|
+
// + slice meant any round where the LLM leg emitted ≥3 fails
|
|
805
|
+
// silently starved every deterministic finding — the model never
|
|
806
|
+
// saw it, the fingerprint set still recorded it, and the stuck
|
|
807
|
+
// detector exited on an issue that was never delivered (2/9 cells
|
|
808
|
+
// shipped a P0 structural defect this way, one displaced by
|
|
809
|
+
// design-token nits). Exactly one slot is reserved so crash-class
|
|
810
|
+
// LLM fails keep the other two.
|
|
811
|
+
const allActionable = selectActionableFeedback(evalResult.issues);
|
|
765
812
|
const issueLines = allActionable.map((issue) => {
|
|
766
813
|
const tag = issue.result === "fail" ? "FAIL" : "WARN";
|
|
767
814
|
return `[${tag}] ${issue.category}${issue.subcategory ? "/" + issue.subcategory : ""}: ${issue.description}\n Fix: ${issue.fix}`;
|
|
@@ -777,7 +824,16 @@ export async function runEvalRound(
|
|
|
777
824
|
evalResult,
|
|
778
825
|
evalRoundsUsed,
|
|
779
826
|
prevModeSubcats: updatedPrevModeSubcats,
|
|
780
|
-
|
|
827
|
+
// DELIVERED fails only (Exp 54 P1 addendum, proposal 2): the
|
|
828
|
+
// stuck detector's premise is "the LLM saw these and did not
|
|
829
|
+
// resolve them" — recording fails that the MAX_FEEDBACK_ISSUES
|
|
830
|
+
// cap cut from the feedback turns truncation into a premature
|
|
831
|
+
// stuck-exit (the model never saw the issue it is blamed for
|
|
832
|
+
// not fixing). An undelivered fail recurring next round now
|
|
833
|
+
// reads as NEW, gets delivered, and earns its fix round.
|
|
834
|
+
prevFailFingerprints: new Set(
|
|
835
|
+
allActionable.filter((i) => i.result === "fail").map(fingerprintFail),
|
|
836
|
+
),
|
|
781
837
|
preWarmedContext,
|
|
782
838
|
evalTokens,
|
|
783
839
|
evalLlmMs,
|
package/src/harness/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type {
|
|
|
27
27
|
} from "./types-public.js";
|
|
28
28
|
export { defaultApplyPatch, applyLineRanges } from "../patch.js";
|
|
29
29
|
export { callLLM, createAgent } from "./llm-router.js";
|
|
30
|
-
export type { AgentConfig, LLMResponse } from "./llm-router.js";
|
|
30
|
+
export type { AgentConfig, LLMResponse, ProviderRetryInfo } from "./llm-router.js";
|
|
31
31
|
export {
|
|
32
32
|
computeHarnessId,
|
|
33
33
|
computeHarnessName,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type Anthropic from '@anthropic-ai/sdk';
|
|
12
12
|
import type {
|
|
13
|
+
ImageBlockParam,
|
|
13
14
|
TextBlock,
|
|
14
15
|
TextBlockParam,
|
|
15
16
|
ToolUseBlock,
|
|
@@ -68,6 +69,36 @@ export interface AgentConfig {
|
|
|
68
69
|
/** Anthropic-only: force the Bedrock-IAM client instead of the direct API client. */
|
|
69
70
|
readonly useBedrock?: boolean;
|
|
70
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Observer invoked once per retried attempt when `apiCall()` (#489)
|
|
74
|
+
* retries a rate-limited (HTTP 429) provider call. Fires AFTER the
|
|
75
|
+
* retry decision is made and BEFORE the delay is awaited — never on
|
|
76
|
+
* the terminal failure/success of the whole call. Purely an
|
|
77
|
+
* observability hook: it never influences whether or how long
|
|
78
|
+
* `apiCall()` retries. If the observer itself throws, `apiCall()`
|
|
79
|
+
* catches it, logs a `console.warn`, and proceeds to the retry
|
|
80
|
+
* delay unaffected — a broken observer cannot turn a recoverable
|
|
81
|
+
* 429 into an unrelated hard failure. Absent (default) is a no-op;
|
|
82
|
+
* `apiCall()` still logs every retry via `console.warn` regardless.
|
|
83
|
+
*/
|
|
84
|
+
onRetry?: (info: ProviderRetryInfo) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One retried attempt's context — passed to `AgentConfig.onRetry`. */
|
|
88
|
+
export interface ProviderRetryInfo {
|
|
89
|
+
readonly provider: AgentConfig['provider'];
|
|
90
|
+
/** 1-based index of this retry (1 = first retry, 2 = second, …). */
|
|
91
|
+
readonly attempt: number;
|
|
92
|
+
/** Total retries `apiCall()` will attempt before giving up. */
|
|
93
|
+
readonly maxAttempts: number;
|
|
94
|
+
/** HTTP status of the failed call, when the caught error carries one. */
|
|
95
|
+
readonly status: number | undefined;
|
|
96
|
+
/** Provider-supplied `Retry-After` value in seconds, when present and within the cap. */
|
|
97
|
+
readonly retryAfterSec: number | undefined;
|
|
98
|
+
/** Delay actually awaited before the next attempt. */
|
|
99
|
+
readonly delayMs: number;
|
|
100
|
+
/** One-line error summary (same shape as the existing `console.error` line). */
|
|
101
|
+
readonly message: string;
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
export interface LLMResponse {
|
|
@@ -76,6 +107,35 @@ export interface LLMResponse {
|
|
|
76
107
|
outputTokens: number;
|
|
77
108
|
}
|
|
78
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Image payload for {@link VisionAgent.callVision}. Base64-encoded
|
|
112
|
+
* bytes plus their media type — the two fields every vision-capable
|
|
113
|
+
* provider SDK needs to reconstruct the image part.
|
|
114
|
+
*/
|
|
115
|
+
export interface VisionImageInput {
|
|
116
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
|
|
117
|
+
base64: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The multimodal (image + text) call surface. Implemented by the
|
|
122
|
+
* providers whose SDK path supports vision input (Anthropic, Google);
|
|
123
|
+
* construct one via {@link createVisionAgent} so the call runs inside
|
|
124
|
+
* the same `apiCall()` retry/observability choke point as every text
|
|
125
|
+
* call — provider 429s retry with `Retry-After` honoring, `onRetry`
|
|
126
|
+
* reaches the caller's structured log, and `routeOverride` governs
|
|
127
|
+
* credentials and model routing instead of `process.env`.
|
|
128
|
+
*/
|
|
129
|
+
export interface VisionAgent {
|
|
130
|
+
callVision(
|
|
131
|
+
model: string,
|
|
132
|
+
systemPrompt: string,
|
|
133
|
+
userPrompt: string,
|
|
134
|
+
image: VisionImageInput,
|
|
135
|
+
maxTokens?: number,
|
|
136
|
+
): Promise<LLMResponse>;
|
|
137
|
+
}
|
|
138
|
+
|
|
79
139
|
export interface LLMTool {
|
|
80
140
|
name: string;
|
|
81
141
|
description: string;
|
|
@@ -182,6 +242,59 @@ function errorSummary(e: unknown): string {
|
|
|
182
242
|
return `${e.constructor.name}${status}${code}: ${e.message.slice(0, 120)}${body}`;
|
|
183
243
|
}
|
|
184
244
|
|
|
245
|
+
/** True when the caught value looks like an HTTP 429 from any provider SDK. */
|
|
246
|
+
function isRateLimited(e: unknown): boolean {
|
|
247
|
+
if (!(e instanceof Error) || !('status' in e)) return false;
|
|
248
|
+
return (e as { status: unknown }).status === 429;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** HTTP status of the caught error, when the SDK attached one. Duck-typed — matches `errorSummary`'s existing precedent. */
|
|
252
|
+
function extractStatus(e: unknown): number | undefined {
|
|
253
|
+
if (!(e instanceof Error) || !('status' in e)) return undefined;
|
|
254
|
+
const status = (e as { status: unknown }).status;
|
|
255
|
+
return typeof status === 'number' ? status : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Duck-typed `Retry-After` extraction. Both `@anthropic-ai/sdk`'s and
|
|
260
|
+
* `openai`'s `APIError` attach a `Headers`-like `.headers` (with
|
|
261
|
+
* `.get()`) to the thrown error at construction time — this is the
|
|
262
|
+
* ONLY point in the call chain where that header is still reachable
|
|
263
|
+
* (confirmed by reading both SDKs' error constructors). Handles the
|
|
264
|
+
* delta-seconds form only (a plain integer/float string) — the rarer
|
|
265
|
+
* HTTP-date form parses to `NaN` and correctly falls through to
|
|
266
|
+
* `undefined` rather than fabricating a value. Google's `ApiError` has
|
|
267
|
+
* no `.headers` at all, so this always returns `undefined` for Google
|
|
268
|
+
* — expected, not a gap.
|
|
269
|
+
*/
|
|
270
|
+
function extractRetryAfterSec(e: unknown): number | undefined {
|
|
271
|
+
if (!(e instanceof Error) || !('headers' in e)) return undefined;
|
|
272
|
+
const headers = (e as { headers?: unknown }).headers;
|
|
273
|
+
if (
|
|
274
|
+
headers === null ||
|
|
275
|
+
typeof headers !== 'object' ||
|
|
276
|
+
!('get' in headers) ||
|
|
277
|
+
typeof (headers as { get?: unknown }).get !== 'function'
|
|
278
|
+
) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const raw = (headers as { get(name: string): string | null }).get('retry-after');
|
|
282
|
+
if (raw === null) return undefined;
|
|
283
|
+
const parsed = Number(raw);
|
|
284
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function sleep(ms: number): Promise<void> {
|
|
288
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Retry budget for `apiCall()` — see the docstring on `apiCall()` itself for the full policy. */
|
|
292
|
+
const MAX_RETRY_ATTEMPTS = 2;
|
|
293
|
+
const RETRY_AFTER_CAP_SEC = 15;
|
|
294
|
+
const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
295
|
+
const DEFAULT_BACKOFF_MAX_MS = 8000;
|
|
296
|
+
const MAX_TOTAL_RETRY_DELAY_MS = 20000;
|
|
297
|
+
|
|
185
298
|
// =============================================================================
|
|
186
299
|
// LLMAgent — abstract base
|
|
187
300
|
// =============================================================================
|
|
@@ -204,8 +317,12 @@ export abstract class LLMAgent {
|
|
|
204
317
|
*/
|
|
205
318
|
protected readonly routeOverride: AgentConfig['routeOverride'];
|
|
206
319
|
|
|
207
|
-
|
|
320
|
+
/** See `AgentConfig.onRetry` (#489). Absent is a no-op. */
|
|
321
|
+
protected readonly onRetry: AgentConfig['onRetry'];
|
|
322
|
+
|
|
323
|
+
constructor(routeOverride?: AgentConfig['routeOverride'], onRetry?: AgentConfig['onRetry']) {
|
|
208
324
|
this.routeOverride = routeOverride;
|
|
325
|
+
this.onRetry = onRetry;
|
|
209
326
|
}
|
|
210
327
|
|
|
211
328
|
protected abstract resolveModel(model: string): string;
|
|
@@ -294,17 +411,92 @@ export abstract class LLMAgent {
|
|
|
294
411
|
}
|
|
295
412
|
|
|
296
413
|
/**
|
|
297
|
-
* Execute an API call
|
|
298
|
-
*
|
|
414
|
+
* Execute an API call, retrying once or twice on HTTP 429
|
|
415
|
+
* (rate-limited) before giving up (#489).
|
|
416
|
+
*
|
|
417
|
+
* Policy: on a 429, honor the provider's `Retry-After` header when
|
|
418
|
+
* present (capped at `RETRY_AFTER_CAP_SEC` — a provider asking for a
|
|
419
|
+
* longer wait is treated as "don't retry", not "wait longer": making
|
|
420
|
+
* the caller sit through 15+ more seconds on a single failed call is
|
|
421
|
+
* a bad experience regardless of what's driving the request);
|
|
422
|
+
* otherwise fall back to exponential backoff with jitter, capped at
|
|
423
|
+
* `DEFAULT_BACKOFF_MAX_MS` per attempt. Stops after
|
|
424
|
+
* `MAX_RETRY_ATTEMPTS` retries OR once the cumulative delay would
|
|
425
|
+
* exceed `MAX_TOTAL_RETRY_DELAY_MS`, whichever comes first. Any
|
|
426
|
+
* non-429 error, or a 429 with no attempts left, is logged and
|
|
427
|
+
* re-thrown immediately — unchanged from the pre-#489 behavior.
|
|
428
|
+
*
|
|
429
|
+
* **Budget scope (final-review correction):** `MAX_TOTAL_RETRY_DELAY_MS`
|
|
430
|
+
* bounds a single `apiCall()` invocation, not a whole generation. A
|
|
431
|
+
* generation issues many sequential `apiCall()` calls (coding turns,
|
|
432
|
+
* tool-result round-trips, eval rounds); under sustained throttling
|
|
433
|
+
* each one can independently add up to `MAX_TOTAL_RETRY_DELAY_MS`, so
|
|
434
|
+
* a generation with k rate-limited calls can add up to roughly `k *
|
|
435
|
+
* MAX_TOTAL_RETRY_DELAY_MS` to how long it holds its caller's
|
|
436
|
+
* resources (e.g. the caller's concurrency slot) — not a flat 20s
|
|
437
|
+
* ceiling. No shared per-generation budget is threaded here by
|
|
438
|
+
* design (accepted ruling, not an oversight): the caller's own
|
|
439
|
+
* queue/admission backstop is what bounds how long other
|
|
440
|
+
* queued work waits, independent of any one generation's retry
|
|
441
|
+
* total.
|
|
299
442
|
*/
|
|
300
443
|
protected async apiCall<T>(fn: () => Promise<T>): Promise<T> {
|
|
301
444
|
const start = Date.now();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
445
|
+
let totalDelayMs = 0;
|
|
446
|
+
for (let attempt = 0; ; attempt++) {
|
|
447
|
+
try {
|
|
448
|
+
return await fn();
|
|
449
|
+
} catch (e: unknown) {
|
|
450
|
+
const ms = Date.now() - start;
|
|
451
|
+
if (attempt >= MAX_RETRY_ATTEMPTS || !isRateLimited(e)) {
|
|
452
|
+
console.error(`[${this.provider}] API error after ${ms}ms: ${errorSummary(e)}`);
|
|
453
|
+
throw e;
|
|
454
|
+
}
|
|
455
|
+
const retryAfterSec = extractRetryAfterSec(e);
|
|
456
|
+
if (retryAfterSec !== undefined && retryAfterSec > RETRY_AFTER_CAP_SEC) {
|
|
457
|
+
console.error(
|
|
458
|
+
`[${this.provider}] API error after ${ms}ms (retry-after ${retryAfterSec}s exceeds ${RETRY_AFTER_CAP_SEC}s cap, not retrying): ${errorSummary(e)}`,
|
|
459
|
+
);
|
|
460
|
+
throw e;
|
|
461
|
+
}
|
|
462
|
+
const delayMs =
|
|
463
|
+
retryAfterSec !== undefined
|
|
464
|
+
? retryAfterSec * 1000
|
|
465
|
+
: Math.min(DEFAULT_BACKOFF_BASE_MS * 2 ** attempt, DEFAULT_BACKOFF_MAX_MS) +
|
|
466
|
+
Math.floor(Math.random() * 250);
|
|
467
|
+
if (totalDelayMs + delayMs > MAX_TOTAL_RETRY_DELAY_MS) {
|
|
468
|
+
console.error(
|
|
469
|
+
`[${this.provider}] API error after ${ms}ms (retry budget exhausted, not retrying): ${errorSummary(e)}`,
|
|
470
|
+
);
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
473
|
+
totalDelayMs += delayMs;
|
|
474
|
+
const info: ProviderRetryInfo = {
|
|
475
|
+
provider: this.provider,
|
|
476
|
+
attempt: attempt + 1,
|
|
477
|
+
maxAttempts: MAX_RETRY_ATTEMPTS,
|
|
478
|
+
status: extractStatus(e),
|
|
479
|
+
retryAfterSec,
|
|
480
|
+
delayMs,
|
|
481
|
+
message: errorSummary(e),
|
|
482
|
+
};
|
|
483
|
+
console.warn(
|
|
484
|
+
`[${this.provider}] rate-limited (attempt ${info.attempt}/${info.maxAttempts}), retrying in ${delayMs}ms`,
|
|
485
|
+
);
|
|
486
|
+
try {
|
|
487
|
+
this.onRetry?.(info);
|
|
488
|
+
} catch (observerError) {
|
|
489
|
+
// Guard, not propagate — the docstring on AgentConfig.onRetry
|
|
490
|
+
// promises this is "purely an observability hook: it never
|
|
491
|
+
// influences whether or how long apiCall() retries". A
|
|
492
|
+
// caller-supplied observer that throws must not turn a
|
|
493
|
+
// recoverable 429 into an unrelated hard failure.
|
|
494
|
+
console.warn(
|
|
495
|
+
`[${this.provider}] onRetry observer threw (ignored, retry proceeds): ${errorSummary(observerError)}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
await sleep(delayMs);
|
|
499
|
+
}
|
|
308
500
|
}
|
|
309
501
|
}
|
|
310
502
|
|
|
@@ -331,11 +523,11 @@ export class AnthropicAgent extends LLMAgent {
|
|
|
331
523
|
}
|
|
332
524
|
|
|
333
525
|
protected async createClient(): Promise<Anthropic | AnthropicBedrock> {
|
|
334
|
-
// Bedrock IAM path:
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// `AnthropicBedrock` is wire-compatible with `Anthropic` for
|
|
338
|
-
// `.messages` API this agent uses.
|
|
526
|
+
// Bedrock IAM path: when CLAUDE_CODE_USE_BEDROCK=1 (set by the
|
|
527
|
+
// deployment's provider routing), auth comes from the ambient AWS
|
|
528
|
+
// credential chain (instance role, env credentials, etc.) — no API
|
|
529
|
+
// key. `AnthropicBedrock` is wire-compatible with `Anthropic` for
|
|
530
|
+
// the `.messages` API this agent uses.
|
|
339
531
|
const useBedrock =
|
|
340
532
|
this.routeOverride?.useBedrock ?? process.env.CLAUDE_CODE_USE_BEDROCK === '1';
|
|
341
533
|
if (useBedrock) {
|
|
@@ -445,6 +637,90 @@ export class AnthropicAgent extends LLMAgent {
|
|
|
445
637
|
}
|
|
446
638
|
}
|
|
447
639
|
|
|
640
|
+
async callVision(
|
|
641
|
+
model: string,
|
|
642
|
+
systemPrompt: string,
|
|
643
|
+
userPrompt: string,
|
|
644
|
+
image: VisionImageInput,
|
|
645
|
+
maxTokens?: number,
|
|
646
|
+
): Promise<LLMResponse> {
|
|
647
|
+
const client = await this.getClient<Anthropic>();
|
|
648
|
+
const traceId = newLlmTraceId();
|
|
649
|
+
const startedAt = Date.now();
|
|
650
|
+
const resolvedModel = this.resolveModel(model);
|
|
651
|
+
|
|
652
|
+
const imageBlock: ImageBlockParam = {
|
|
653
|
+
type: 'image',
|
|
654
|
+
source: {
|
|
655
|
+
type: 'base64',
|
|
656
|
+
media_type: image.mediaType,
|
|
657
|
+
data: image.base64,
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const response = await this.apiCall(() =>
|
|
663
|
+
client.messages.create({
|
|
664
|
+
model: resolvedModel,
|
|
665
|
+
max_tokens: maxTokens ?? 1500,
|
|
666
|
+
system: systemPrompt,
|
|
667
|
+
messages: [
|
|
668
|
+
{
|
|
669
|
+
role: 'user',
|
|
670
|
+
content: [imageBlock, { type: 'text', text: userPrompt }],
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const text = response.content
|
|
677
|
+
.filter((block): block is TextBlock => block.type === 'text')
|
|
678
|
+
.map((block) => block.text)
|
|
679
|
+
.join('');
|
|
680
|
+
|
|
681
|
+
const endedAt = Date.now();
|
|
682
|
+
// The image bytes stay OUT of the trace — a base64 screenshot
|
|
683
|
+
// would balloon every event; the prompts identify the call.
|
|
684
|
+
emitLlmTraceEvent({
|
|
685
|
+
id: traceId,
|
|
686
|
+
at: startedAt,
|
|
687
|
+
endedAt,
|
|
688
|
+
durationMs: endedAt - startedAt,
|
|
689
|
+
provider: 'anthropic',
|
|
690
|
+
model: resolvedModel,
|
|
691
|
+
kind: 'callVision',
|
|
692
|
+
systemPrompt,
|
|
693
|
+
userPrompt,
|
|
694
|
+
result: {
|
|
695
|
+
inputTokens: response.usage.input_tokens,
|
|
696
|
+
outputTokens: response.usage.output_tokens,
|
|
697
|
+
text,
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
text,
|
|
703
|
+
inputTokens: response.usage.input_tokens,
|
|
704
|
+
outputTokens: response.usage.output_tokens,
|
|
705
|
+
};
|
|
706
|
+
} catch (e) {
|
|
707
|
+
const endedAt = Date.now();
|
|
708
|
+
emitLlmTraceEvent({
|
|
709
|
+
id: traceId,
|
|
710
|
+
at: startedAt,
|
|
711
|
+
endedAt,
|
|
712
|
+
durationMs: endedAt - startedAt,
|
|
713
|
+
provider: 'anthropic',
|
|
714
|
+
model: resolvedModel,
|
|
715
|
+
kind: 'callVision',
|
|
716
|
+
systemPrompt,
|
|
717
|
+
userPrompt,
|
|
718
|
+
error: { message: e instanceof Error ? e.message : String(e) },
|
|
719
|
+
});
|
|
720
|
+
throw e;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
448
724
|
async callTools(
|
|
449
725
|
model: string,
|
|
450
726
|
systemPrompt: string,
|
|
@@ -997,6 +1273,86 @@ export class GoogleAgent extends LLMAgent {
|
|
|
997
1273
|
};
|
|
998
1274
|
}
|
|
999
1275
|
|
|
1276
|
+
async callVision(
|
|
1277
|
+
model: string,
|
|
1278
|
+
systemPrompt: string,
|
|
1279
|
+
userPrompt: string,
|
|
1280
|
+
image: VisionImageInput,
|
|
1281
|
+
maxTokens?: number,
|
|
1282
|
+
): Promise<LLMResponse> {
|
|
1283
|
+
const client = await this.getClient<GoogleGenAI>();
|
|
1284
|
+
const traceId = newLlmTraceId();
|
|
1285
|
+
const startedAt = Date.now();
|
|
1286
|
+
const resolvedModel = this.resolveModel(model);
|
|
1287
|
+
|
|
1288
|
+
try {
|
|
1289
|
+
const response = await this.apiCall(() =>
|
|
1290
|
+
client.models.generateContent({
|
|
1291
|
+
model: resolvedModel,
|
|
1292
|
+
contents: [
|
|
1293
|
+
{
|
|
1294
|
+
role: 'user',
|
|
1295
|
+
parts: [
|
|
1296
|
+
{ inlineData: { mimeType: image.mediaType, data: image.base64 } },
|
|
1297
|
+
{ text: userPrompt },
|
|
1298
|
+
],
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
config: {
|
|
1302
|
+
systemInstruction: systemPrompt,
|
|
1303
|
+
...(maxTokens ? { maxOutputTokens: maxTokens } : {}),
|
|
1304
|
+
},
|
|
1305
|
+
}),
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1308
|
+
const text =
|
|
1309
|
+
response.candidates?.[0]?.content?.parts?.map((p) => p.text || '').join('') || '';
|
|
1310
|
+
const usage = response.usageMetadata;
|
|
1311
|
+
const result: LLMResponse = {
|
|
1312
|
+
text,
|
|
1313
|
+
inputTokens: usage?.promptTokenCount ?? 0,
|
|
1314
|
+
outputTokens: usage?.candidatesTokenCount ?? 0,
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
const endedAt = Date.now();
|
|
1318
|
+
// The image bytes stay OUT of the trace — a base64 screenshot
|
|
1319
|
+
// would balloon every event; the prompts identify the call.
|
|
1320
|
+
emitLlmTraceEvent({
|
|
1321
|
+
id: traceId,
|
|
1322
|
+
at: startedAt,
|
|
1323
|
+
endedAt,
|
|
1324
|
+
durationMs: endedAt - startedAt,
|
|
1325
|
+
provider: 'google',
|
|
1326
|
+
model: resolvedModel,
|
|
1327
|
+
kind: 'callVision',
|
|
1328
|
+
systemPrompt,
|
|
1329
|
+
userPrompt,
|
|
1330
|
+
result: {
|
|
1331
|
+
inputTokens: result.inputTokens,
|
|
1332
|
+
outputTokens: result.outputTokens,
|
|
1333
|
+
text,
|
|
1334
|
+
},
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
return result;
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
const endedAt = Date.now();
|
|
1340
|
+
emitLlmTraceEvent({
|
|
1341
|
+
id: traceId,
|
|
1342
|
+
at: startedAt,
|
|
1343
|
+
endedAt,
|
|
1344
|
+
durationMs: endedAt - startedAt,
|
|
1345
|
+
provider: 'google',
|
|
1346
|
+
model: resolvedModel,
|
|
1347
|
+
kind: 'callVision',
|
|
1348
|
+
systemPrompt,
|
|
1349
|
+
userPrompt,
|
|
1350
|
+
error: { message: e instanceof Error ? e.message : String(e) },
|
|
1351
|
+
});
|
|
1352
|
+
throw e;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1000
1356
|
async callTools(
|
|
1001
1357
|
model: string,
|
|
1002
1358
|
systemPrompt: string,
|
|
@@ -1479,15 +1835,38 @@ export function createAgent(providerOrConfig: AgentConfig['provider'] | AgentCon
|
|
|
1479
1835
|
typeof providerOrConfig === 'string' ? providerOrConfig : providerOrConfig.provider;
|
|
1480
1836
|
const routeOverride =
|
|
1481
1837
|
typeof providerOrConfig === 'string' ? undefined : providerOrConfig.routeOverride;
|
|
1838
|
+
const onRetry = typeof providerOrConfig === 'string' ? undefined : providerOrConfig.onRetry;
|
|
1482
1839
|
switch (provider) {
|
|
1483
1840
|
case 'anthropic':
|
|
1484
|
-
return new AnthropicAgent(routeOverride);
|
|
1841
|
+
return new AnthropicAgent(routeOverride, onRetry);
|
|
1485
1842
|
case 'openai':
|
|
1486
|
-
return new OpenAIAgent(routeOverride);
|
|
1843
|
+
return new OpenAIAgent(routeOverride, onRetry);
|
|
1487
1844
|
case 'google':
|
|
1488
|
-
return new GoogleAgent(routeOverride);
|
|
1845
|
+
return new GoogleAgent(routeOverride, onRetry);
|
|
1489
1846
|
case 'openrouter':
|
|
1490
|
-
return new OpenRouterAgent(routeOverride);
|
|
1847
|
+
return new OpenRouterAgent(routeOverride, onRetry);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/** Providers whose agent implements {@link VisionAgent}. */
|
|
1852
|
+
export type VisionProvider = 'anthropic' | 'google';
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Construct an agent for a multimodal (image + text) call. Same
|
|
1856
|
+
* threading contract as {@link createAgent} — `routeOverride` and
|
|
1857
|
+
* `onRetry` from the config reach the constructed agent — with the
|
|
1858
|
+
* provider narrowed to the ones whose SDK path supports vision input,
|
|
1859
|
+
* so the returned type carries `callVision` statically instead of
|
|
1860
|
+
* every caller re-proving it.
|
|
1861
|
+
*/
|
|
1862
|
+
export function createVisionAgent(
|
|
1863
|
+
config: AgentConfig & { provider: VisionProvider },
|
|
1864
|
+
): LLMAgent & VisionAgent {
|
|
1865
|
+
switch (config.provider) {
|
|
1866
|
+
case 'anthropic':
|
|
1867
|
+
return new AnthropicAgent(config.routeOverride, config.onRetry);
|
|
1868
|
+
case 'google':
|
|
1869
|
+
return new GoogleAgent(config.routeOverride, config.onRetry);
|
|
1491
1870
|
}
|
|
1492
1871
|
}
|
|
1493
1872
|
|
package/src/harness/runtime.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface AgentSpec {
|
|
|
54
54
|
* it. Absent for every pre-existing caller.
|
|
55
55
|
*/
|
|
56
56
|
routeOverride?: AgentConfig["routeOverride"];
|
|
57
|
+
/**
|
|
58
|
+
* Retry observer (#489) — see `AgentConfig.onRetry`. Threaded
|
|
59
|
+
* through unchanged so `createAgent()` can notify the caller when
|
|
60
|
+
* `apiCall()` retries a rate-limited request. Absent for every
|
|
61
|
+
* pre-existing caller (no-op).
|
|
62
|
+
*/
|
|
63
|
+
onRetry?: AgentConfig["onRetry"];
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
export interface SingleComponentParams {
|
package/src/index.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* `@ggui-ai/ui-gen` — open-source UI generation harness.
|
|
3
3
|
*
|
|
4
4
|
* Implements the `UiGenerator` contract from `@ggui-ai/mcp-server-core`.
|
|
5
|
-
* `createUiGenerator()` returns a callable provider-backed generator
|
|
5
|
+
* `createUiGenerator()` returns a callable provider-backed generator.
|
|
6
|
+
* Provider routing, compilation, and the system prompt are all
|
|
7
|
+
* internal to the harness — the factory takes no `adapter` option:
|
|
6
8
|
*
|
|
7
9
|
* import { createUiGenerator } from '@ggui-ai/ui-gen';
|
|
8
|
-
* import { createAnthropicAdapter } from '@ggui-ai/ui-gen/providers';
|
|
9
10
|
*
|
|
10
|
-
* const generator = createUiGenerator(
|
|
11
|
-
* adapter: createAnthropicAdapter(),
|
|
12
|
-
* });
|
|
11
|
+
* const generator = createUiGenerator();
|
|
13
12
|
* const result = await generator.generate({ request, llm, providerKey, blueprints });
|
|
14
13
|
*
|
|
15
14
|
* The `./harness` / `./workflows` / `./classifier` / `./fragments`
|
|
@@ -21,6 +20,7 @@ export {
|
|
|
21
20
|
extractComponentCode,
|
|
22
21
|
} from './create-ui-generator.js';
|
|
23
22
|
export type { CreateUiGeneratorOptions } from './create-ui-generator.js';
|
|
23
|
+
export type { ProviderRetryInfo } from './harness/llm-router.js';
|
|
24
24
|
|
|
25
25
|
// Generator registry seam re-export. The interface + slug helpers live
|
|
26
26
|
// in `@ggui-ai/mcp-server-core`; the in-memory factory lives in
|