@ggui-ai/ui-gen 0.9.0 → 0.10.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.
Files changed (87) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +9 -2
  2. package/dist/adapters/generation-dispatch.js +547 -85
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +28 -11
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +5 -3
  7. package/dist/advanced/index.js +563 -92
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +109 -3
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.js +14 -2
  12. package/dist/boilerplate.js.map +1 -1
  13. package/dist/check/index.d.ts +1 -1
  14. package/dist/check/index.js +11 -1
  15. package/dist/check/index.js.map +1 -1
  16. package/dist/{create-ui-generator-B9kV4U9h.d.ts → create-ui-generator-CF0EL0Li.d.ts} +13 -1
  17. package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
  18. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  19. package/dist/evaluation/axis-checks/index.js +101 -0
  20. package/dist/evaluation/axis-checks/index.js.map +1 -1
  21. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  22. package/dist/evaluation/axis-checks/registry.js +101 -0
  23. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  24. package/dist/evaluation/index.d.ts +46 -3
  25. package/dist/evaluation/index.js +358 -17
  26. package/dist/evaluation/index.js.map +1 -1
  27. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  28. package/dist/harness/check/runtime-render/index.js +110 -3
  29. package/dist/harness/check/runtime-render/index.js.map +1 -1
  30. package/dist/harness/index.d.ts +2 -2
  31. package/dist/harness/index.js +263 -18
  32. package/dist/harness/index.js.map +1 -1
  33. package/dist/harness/llm-trace-sink.d.ts +1 -1
  34. package/dist/harness/llm-trace-sink.js.map +1 -1
  35. package/dist/harness/prompts.js +17 -3
  36. package/dist/harness/prompts.js.map +1 -1
  37. package/dist/harness/result-types.d.ts +1 -1
  38. package/dist/harness/runtime.d.ts +9 -2
  39. package/dist/harness/runtime.js +20 -6
  40. package/dist/harness/runtime.js.map +1 -1
  41. package/dist/harness/types-public.d.ts +1 -1
  42. package/dist/harness/types-public.js +14 -2
  43. package/dist/harness/types-public.js.map +1 -1
  44. package/dist/harness/validator-trace-sink.d.ts +1 -1
  45. package/dist/index.d.ts +5 -3
  46. package/dist/index.js +563 -92
  47. package/dist/index.js.map +1 -1
  48. package/dist/{llm-router-1TyI0_TV.d.ts → llm-router-BEVtbAb9.d.ts} +61 -4
  49. package/dist/patch.d.ts +1 -1
  50. package/dist/provider-adapter.d.ts +16 -6
  51. package/dist/provider-adapter.js.map +1 -1
  52. package/dist/providers/index.d.ts +19 -6
  53. package/dist/providers/index.js.map +1 -1
  54. package/dist/{render-check-CNZjHD5g.d.ts → render-check-BNYGVDJd.d.ts} +1 -1
  55. package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
  56. package/dist/workflows.d.ts +1 -1
  57. package/package.json +12 -12
  58. package/src/adapters/generation-dispatch.ts +9 -0
  59. package/src/adapters/openrouter/client.ts +2 -2
  60. package/src/adapters/openrouter/types.ts +10 -0
  61. package/src/boilerplate/generate.ts +10 -2
  62. package/src/boilerplate/json-schema-ts.ts +22 -2
  63. package/src/boilerplate/system-prompt.ts +1 -0
  64. package/src/compile.ts +1 -1
  65. package/src/contract-context.ts +10 -2
  66. package/src/create-ui-generator.ts +14 -0
  67. package/src/design-system-docs.ts +6 -4
  68. package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
  69. package/src/evaluation/axis-checks/registry.ts +2 -0
  70. package/src/evaluation/index.ts +11 -0
  71. package/src/evaluation/llm-evaluator.ts +14 -1
  72. package/src/evaluation/visual-evaluator.ts +44 -65
  73. package/src/harness/check/runtime-render/adapter.ts +18 -0
  74. package/src/harness/check/runtime-render/index.ts +8 -0
  75. package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
  76. package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
  77. package/src/harness/check/runtime-render/render-check.ts +72 -1
  78. package/src/harness/coding/run-eval-round.ts +61 -5
  79. package/src/harness/index.ts +1 -1
  80. package/src/harness/llm-router.ts +397 -18
  81. package/src/harness/llm-trace-sink.ts +2 -1
  82. package/src/harness/runtime.ts +7 -0
  83. package/src/index.ts +5 -5
  84. package/src/provider-adapter.ts +16 -6
  85. package/src/providers/bedrock.ts +5 -4
  86. package/src/providers/index.ts +16 -4
  87. package/src/tools/render-check.ts +19 -7
@@ -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
- constructor(routeOverride?: AgentConfig['routeOverride']) {
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. No retry — if it fails, it fails.
298
- * Logs the error with provider context and re-throws.
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
- try {
303
- return await fn();
304
- } catch (e: unknown) {
305
- const ms = Date.now() - start;
306
- console.error(`[${this.provider}] API error after ${ms}ms: ${errorSummary(e)}`);
307
- throw e;
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: `resolveRoute` (provider-router) sets
335
- // CLAUDE_CODE_USE_BEDROCK=1 and clears ANTHROPIC_API_KEY for the
336
- // pool-funded cloud pod. Auth is the pod's IRSA role — no API key.
337
- // `AnthropicBedrock` is wire-compatible with `Anthropic` for the
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
 
@@ -39,7 +39,8 @@ export type LlmTraceKind =
39
39
  | 'callText'
40
40
  | 'callTools'
41
41
  | 'callWithTools'
42
- | 'callStructured';
42
+ | 'callStructured'
43
+ | 'callVision';
43
44
 
44
45
  /**
45
46
  * One LLM call. Emitted **after** the call completes (success or error)
@@ -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
@@ -1,7 +1,18 @@
1
1
  /**
2
2
  * Provider adapter contract — the narrow surface every concrete LLM
3
- * provider client (Anthropic, Google, OpenAI, OpenRouter) implements
4
- * before the generator wires them in.
3
+ * provider client (Anthropic, Google, OpenAI, OpenRouter, Bedrock)
4
+ * implements.
5
+ *
6
+ * **Real role:** a provider-agnostic one-shot LLM caller for
7
+ * lightweight callers that need a single `complete()` round-trip — NOT
8
+ * a generation harness. The primary consumer is the negotiator's LLM
9
+ * seam (`@ggui-ai/mcp-server`'s `llm-backed-negotiator.ts`, via
10
+ * {@link selectAdapter} from `./providers/index.js`); other lightweight
11
+ * one-shot callers use it the same way. It is explicitly NOT part of
12
+ * the generation dispatch path: `createUiGenerator()` accepts no
13
+ * adapter option, and generation-call 429 retry/backoff lives entirely
14
+ * in `harness/llm-router.ts`'s `apiCall()` — a separate retry story for
15
+ * a separate call shape (an agentic SDK loop, not a single completion).
5
16
  *
6
17
  * The contract is deliberately defined independently of concrete
7
18
  * adapters so:
@@ -9,10 +20,9 @@
9
20
  * - Adapter implementations can be written against a stable surface —
10
21
  * every concrete adapter MUST pass
11
22
  * {@link providerAdapterContract} or it isn't a `ProviderAdapter`.
12
- * - Downstream callers (`createUiGenerator`, render handlers, retry /
13
- * backoff plumbing) build against ONE structured failure shape
14
- * ({@link ProviderError}) instead of inventing per-provider error
15
- * UX.
23
+ * - Downstream one-shot callers build against ONE structured failure
24
+ * shape ({@link ProviderError}) instead of inventing per-provider
25
+ * error UX.
16
26
  * - The negotiator + cache layers can branch on
17
27
  * `error.kind === 'rate-limited'` etc. without parsing strings.
18
28
  *
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * ## Why this adapter exists
9
9
  *
10
- * Anyone running the generator on an AWS-credentialed host (EC2, ECS,
11
- * Lambda, EKS) can target Bedrock without managing API keys: IAM is
10
+ * Anyone running a one-shot LLM caller on an AWS-credentialed host
11
+ * (EC2, ECS, Lambda, EKS) can target Bedrock without managing API keys: IAM is
12
12
  * the auth boundary, AWS rotates host credentials automatically, and
13
13
  * a misconfigured IAM role surfaces as a clear `AccessDeniedException`
14
14
  * the SDK funnels through `mapError`. The alternative — parking a
@@ -198,8 +198,9 @@ export interface BedrockAdapterOptions {
198
198
  *
199
199
  * No API key — IAM is the auth boundary. The returned `ProviderAdapter`
200
200
  * satisfies the same contract as `createAnthropicAdapter`, so it
201
- * slots into `createUiGenerator({ adapter })` interchangeably (modulo
202
- * the per-provider model-id namespace differences).
201
+ * slots into any one-shot `ProviderAdapter` caller (e.g. `selectAdapter`)
202
+ * interchangeably (modulo the per-provider model-id namespace
203
+ * differences).
203
204
  */
204
205
  export function createBedrockAdapter(
205
206
  options: BedrockAdapterOptions = {},
@@ -5,6 +5,15 @@
5
5
  * providers ggui supports today: Anthropic (direct API), Google,
6
6
  * OpenAI, OpenRouter, and AWS Bedrock (Anthropic models via IAM).
7
7
  *
8
+ * **Real role:** a provider-agnostic one-shot LLM caller for
9
+ * lightweight callers that need a single `complete()` round-trip — NOT
10
+ * the generation dispatch path. `createUiGenerator()` accepts no
11
+ * adapter option; generation-call retry/backoff lives entirely in
12
+ * `harness/llm-router.ts`'s `apiCall()`. The primary consumer is the
13
+ * negotiator's LLM seam (`@ggui-ai/mcp-server`'s
14
+ * `llm-backed-negotiator.ts`, via {@link selectAdapter}); other
15
+ * lightweight one-shot callers use it the same way.
16
+ *
8
17
  * Every adapter satisfies the structural
9
18
  * {@link import('../provider-adapter.js').ProviderAdapter} contract.
10
19
  * The four direct-API adapters compose
@@ -15,15 +24,18 @@
15
24
  * because rolling our own AWS SigV4 signer for one provider isn't a
16
25
  * reasonable trade.
17
26
  *
18
- * Typical usage from the CLI:
27
+ * Typical usage — a one-shot completion:
19
28
  *
20
29
  * ```ts
21
30
  * import { createAnthropicAdapter } from '@ggui-ai/ui-gen/providers';
22
- * import { createUiGenerator } from '@ggui-ai/ui-gen';
23
31
  *
24
32
  * const adapter = createAnthropicAdapter();
25
- * const generator = createUiGenerator({ adapter });
26
- * const result = await generator.generate({ request, llm, providerKey, blueprints });
33
+ * const result = await adapter.complete({
34
+ * apiKey,
35
+ * route: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' },
36
+ * systemPrompt,
37
+ * userPrompt,
38
+ * });
27
39
  * ```
28
40
  *
29
41
  * Bedrock pool path (no API key — IAM at process boot):