plugin-ai-api 1.0.15 → 1.0.21

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 (92) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/757.a01403fb7a1bea01.js +10 -0
  5. package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
  6. package/dist/client/97.72979a11a067a7c9.js +10 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
  11. package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/97.29c663318eebbd57.js +10 -0
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +105 -10
  18. package/dist/locale/vi-VN.json +105 -0
  19. package/dist/locale/zh-CN.json +105 -10
  20. package/dist/server/billing.js +331 -0
  21. package/dist/server/collections/ai-api-config.js +18 -0
  22. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  23. package/dist/server/collections/ai-api-model-prices.js +55 -0
  24. package/dist/server/collections/ai-api-usage-records.js +9 -0
  25. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  26. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  27. package/dist/server/plugin.js +36 -3
  28. package/dist/server/resource/ai-api-config.js +25 -0
  29. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  30. package/dist/server/routes/agent-completions.js +62 -51
  31. package/dist/server/routes/auth.js +11 -1
  32. package/dist/server/routes/chat-completions.js +157 -6
  33. package/dist/server/routes/completions.js +20 -3
  34. package/dist/server/routes/models.js +78 -20
  35. package/dist/server/routes/router.js +108 -23
  36. package/dist/server/usage.js +19 -2
  37. package/dist/server/utils/app-observability.js +110 -0
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/validation.js +120 -0
  40. package/dist/swagger.js +32 -1
  41. package/package.json +1 -1
  42. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  43. package/src/client/locale.ts +11 -21
  44. package/src/client/plugin.tsx +82 -48
  45. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  46. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  47. package/src/client-v2/locale.ts +21 -0
  48. package/src/client-v2/pages/GeneralPage.tsx +183 -0
  49. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  50. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  51. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  52. package/src/client-v2/pages/UsagePage.tsx +248 -0
  53. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  54. package/src/client-v2/pages/api.ts +16 -0
  55. package/src/client-v2/plugin.tsx +62 -4
  56. package/src/constants.ts +21 -0
  57. package/src/locale/en-US.json +105 -10
  58. package/src/locale/vi-VN.json +105 -0
  59. package/src/locale/zh-CN.json +105 -10
  60. package/src/server/__tests__/app-observability.test.ts +98 -0
  61. package/src/server/__tests__/billing-quota.test.ts +134 -0
  62. package/src/server/__tests__/billing.test.ts +33 -0
  63. package/src/server/__tests__/models.test.ts +74 -0
  64. package/src/server/__tests__/request-body.test.ts +310 -0
  65. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  66. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  67. package/src/server/__tests__/usage-route.test.ts +4 -0
  68. package/src/server/billing.ts +387 -0
  69. package/src/server/collections/ai-api-config.ts +69 -51
  70. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  71. package/src/server/collections/ai-api-model-prices.ts +25 -0
  72. package/src/server/collections/ai-api-usage-records.ts +9 -0
  73. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  74. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  75. package/src/server/plugin.ts +47 -5
  76. package/src/server/resource/ai-api-config.ts +105 -74
  77. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  78. package/src/server/routes/agent-completions.ts +77 -62
  79. package/src/server/routes/auth.ts +14 -1
  80. package/src/server/routes/chat-completions.ts +275 -6
  81. package/src/server/routes/completions.ts +27 -4
  82. package/src/server/routes/models.ts +290 -195
  83. package/src/server/routes/router.ts +152 -27
  84. package/src/server/usage.ts +19 -1
  85. package/src/server/utils/app-observability.ts +105 -0
  86. package/src/server/utils/streaming.ts +13 -1
  87. package/src/server/validation.ts +89 -0
  88. package/src/swagger.ts +38 -1
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  91. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
  92. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -19,10 +19,17 @@ import {
19
19
  OpenAIToolCallChunk,
20
20
  } from '../utils/openai-format';
21
21
  import { resolveModelString } from '../utils/resolve-service';
22
- import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
22
+ import {
23
+ createRequestAbortController,
24
+ isClientDisconnected,
25
+ isStreamingRequested,
26
+ writeResponse,
27
+ } from '../utils/streaming';
23
28
  import { checkEmployeeAccess } from '../middleware/role-permission';
24
29
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
25
30
  import type PluginAiApiServer from '../plugin';
31
+ import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
32
+ import { markAiApiFirstProviderOutput } from '../utils/app-observability';
26
33
 
27
34
  /**
28
35
  * POST /api/ai-llm/v1/chat/completions
@@ -46,6 +53,30 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
46
53
  return;
47
54
  }
48
55
 
56
+ const messageProblem = findMessageProblem(body.messages);
57
+ if (messageProblem) {
58
+ ctx.status = 400;
59
+ ctx.body = toOpenAIError(
60
+ 400,
61
+ `Invalid messages[${messageProblem.index}]: ${messageProblem.reason}.`,
62
+ 'invalid_request_error',
63
+ 'invalid_message',
64
+ );
65
+ return;
66
+ }
67
+
68
+ const blockProblem = findContentBlockProblem(body.messages);
69
+ if (blockProblem) {
70
+ ctx.status = 400;
71
+ ctx.body = toOpenAIError(
72
+ 400,
73
+ `Invalid content block in messages[${blockProblem.index}]: ${blockProblem.reason}.`,
74
+ 'invalid_request_error',
75
+ 'invalid_content_block',
76
+ );
77
+ return;
78
+ }
79
+
49
80
  // ─── Reject unsupported n parameter ───
50
81
  if (body.n !== undefined && body.n !== null && body.n !== 1) {
51
82
  ctx.status = 400;
@@ -121,6 +152,8 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
121
152
  return;
122
153
  }
123
154
 
155
+ await prepareLlmBilling(ctx, resolved);
156
+
124
157
  const providerRequestParameters = getProviderRequestParameters(body);
125
158
  const modelOptions: Record<string, unknown> = {
126
159
  model: modelId,
@@ -176,9 +209,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
176
209
  // ─── Build message tuples for LangChain model ───
177
210
  // LangChain chat models accept [role, content] tuples or BaseMessage objects.
178
211
  // We use tuples to avoid importing @langchain/core directly.
212
+ //
213
+ // `content` may be a string OR an array of content blocks (OpenAI vision
214
+ // format: [{type:'text'}, {type:'image_url', image_url:{url:'data:...'}}]).
215
+ // Arrays must be forwarded structurally — stringifying them would turn an
216
+ // image into literal JSON text and the model would never see the picture.
179
217
  const langchainMessages = messages.map((msg: any) => {
180
218
  const role = msg.role === 'assistant' ? 'ai' : msg.role;
181
- const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
219
+ const content = normalizeMessageContent(msg.content);
182
220
  if (msg.role === 'assistant' && msg.tool_calls) {
183
221
  return {
184
222
  role,
@@ -190,13 +228,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
190
228
  if (msg.role === 'tool') {
191
229
  return { role: 'tool', content, tool_call_id: msg.tool_call_id, name: msg.name };
192
230
  }
193
- return [role, content] as [string, string];
231
+ return [role, content] as [string, MessageContent];
194
232
  });
195
233
 
196
234
  const completionId = generateCompletionId();
197
235
  const baseModel = provider.createModel();
198
236
  applyProviderRequestParameters(baseModel, providerRequestParameters);
199
237
  const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
238
+ markLlmProviderAttempted(ctx);
200
239
 
201
240
  if (stream) {
202
241
  // ─── Streaming mode ───
@@ -222,8 +261,15 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
222
261
  } catch (err) {
223
262
  ctx.log.error('AI API chat completions error:', err);
224
263
  if (!ctx.res.headersSent) {
225
- ctx.status = 500;
226
- ctx.body = toOpenAIError(500, getErrorMessage(err, 'Internal server error'), 'server_error');
264
+ const isQuotaError = err instanceof AiApiQuotaError;
265
+ ctx.status = isQuotaError ? 429 : 500;
266
+ if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
267
+ ctx.body = toOpenAIError(
268
+ ctx.status,
269
+ getErrorMessage(err, 'Internal server error'),
270
+ isQuotaError ? 'quota_error' : 'server_error',
271
+ isQuotaError ? err.code : undefined,
272
+ );
227
273
  }
228
274
  }
229
275
  }
@@ -315,6 +361,7 @@ async function handleStreamingCompletion(
315
361
  }
316
362
 
317
363
  if (content) {
364
+ markAiApiFirstProviderOutput(ctx);
318
365
  await writeResponse(
319
366
  ctx,
320
367
  formatSSE(
@@ -329,6 +376,7 @@ async function handleStreamingCompletion(
329
376
 
330
377
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
331
378
  if (toolCallChunks.length) {
379
+ markAiApiFirstProviderOutput(ctx);
332
380
  finishReason = 'tool_calls';
333
381
  await writeResponse(
334
382
  ctx,
@@ -359,6 +407,7 @@ async function handleStreamingCompletion(
359
407
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
360
408
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
361
409
  } catch (err) {
410
+ const cancelled = isClientDisconnected(ctx, err);
362
411
  ctx.log.error('AI API streaming error:', err);
363
412
  // Send error as SSE event before closing
364
413
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
@@ -373,7 +422,11 @@ async function handleStreamingCompletion(
373
422
  );
374
423
  }
375
424
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
376
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'stream_error' };
425
+ ctx.state.aiApiStreamResult = {
426
+ succeeded: false,
427
+ id: completionId,
428
+ errorCode: cancelled ? 'client_disconnected' : 'stream_error',
429
+ };
377
430
  } finally {
378
431
  requestAbort.dispose();
379
432
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -384,6 +437,222 @@ function getErrorMessage(error: unknown, fallback: string) {
384
437
  return error instanceof Error && error.message ? error.message : fallback;
385
438
  }
386
439
 
440
+ /**
441
+ * Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
442
+ * native equivalent.
443
+ *
444
+ * Anything else is rejected rather than forwarded: the LangChain block
445
+ * converters are if/else-if chains with no fallback branch, so an unrecognized
446
+ * block (OpenAI's `{type:'file'}` on Anthropic, for example) yields nothing and
447
+ * the model answers as if the attachment was never sent. A 400 is far easier to
448
+ * debug than a confidently wrong completion.
449
+ */
450
+ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
451
+
452
+ /**
453
+ * Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
454
+ * accepts (`\w+/\w+`, standard base64). A looser pattern here would admit URLs
455
+ * the provider adapter then fails to parse — it falls through to `new URL()`,
456
+ * sees the `data:` protocol and throws, which reaches the client as a 500.
457
+ * Compound subtypes such as `image/svg+xml` are rejected for that reason.
458
+ */
459
+ const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
460
+
461
+ /**
462
+ * The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
463
+ * `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
464
+ * calls `atob` on the payload, which throws a DOMException for each of them, and
465
+ * that escapes as an HTTP 500. Re-encoding is the cheapest exact check — the
466
+ * canonical form of a valid payload is the payload itself.
467
+ */
468
+ function isDecodableBase64(payload: string): boolean {
469
+ try {
470
+ return Buffer.from(payload, 'base64').toString('base64') === payload;
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ export interface ContentBlockProblem {
477
+ index: number;
478
+ reason: string;
479
+ }
480
+
481
+ /**
482
+ * Roles `_constructMessageFromParams` can turn into a message. Anything else
483
+ * reaches its final `else` and throws MESSAGE_COERCION_FAILURE.
484
+ *
485
+ * `function` is deliberately absent: OpenAI deprecated it, and LangChain has no
486
+ * branch for it despite mapping the class name internally.
487
+ */
488
+ const SUPPORTED_MESSAGE_ROLES = new Set(['system', 'developer', 'user', 'human', 'assistant', 'ai', 'tool']);
489
+
490
+ /**
491
+ * Validate the shape of each message before any provider work happens.
492
+ *
493
+ * Requiring only "non-empty array" lets `messages: [null]` through to
494
+ * `messages.some((m) => m.role === 'system')`, which throws a TypeError and is
495
+ * reported as a 500. An unsupported role travels further still and dies inside
496
+ * LangChain with MESSAGE_COERCION_FAILURE. Both are caller errors, so both
497
+ * should be a 400 that names the offending index.
498
+ */
499
+ export function findMessageProblem(messages: unknown[]): ContentBlockProblem | undefined {
500
+ for (const [index, message] of messages.entries()) {
501
+ if (!isRecord(message)) return { index, reason: 'each message must be an object' };
502
+
503
+ const role = typeof message.role === 'string' ? message.role : undefined;
504
+ if (!role) return { index, reason: "each message requires a string 'role' field" };
505
+ if (!SUPPORTED_MESSAGE_ROLES.has(role)) {
506
+ return {
507
+ index,
508
+ reason: `role '${role}' is not supported — use one of ` + `${[...SUPPORTED_MESSAGE_ROLES].join(', ')}`,
509
+ };
510
+ }
511
+
512
+ if (role === 'tool' && typeof message.tool_call_id !== 'string') {
513
+ return { index, reason: "a 'tool' message requires a string 'tool_call_id' field" };
514
+ }
515
+
516
+ const { content } = message;
517
+ const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
518
+ if (content === undefined || content === null) {
519
+ // An assistant turn that only calls tools legitimately carries no content.
520
+ if ((role === 'assistant' || role === 'ai') && hasToolCalls) continue;
521
+ return { index, reason: "each message requires a 'content' field" };
522
+ }
523
+ if (typeof content !== 'string' && !Array.isArray(content)) {
524
+ return { index, reason: "'content' must be a string or an array of content blocks" };
525
+ }
526
+ }
527
+ return undefined;
528
+ }
529
+
530
+ /**
531
+ * Validate the multimodal content blocks of a chat request.
532
+ *
533
+ * Checking `type` alone is not enough. The provider adapters either drop or
534
+ * throw on malformed blocks, and both outcomes surface badly:
535
+ *
536
+ * - A block whose payload fails every branch of the converter yields nothing,
537
+ * so the model answers as if the attachment was never sent.
538
+ * - `_formatImage` throws on a malformed or non-http(s) URL, and that escapes as
539
+ * a generic HTTP 500 instead of telling the caller what was wrong.
540
+ * - `parseBase64DataUrl` matches any `data:<type>/<subtype>;base64,` URL without
541
+ * checking for `image/*`, so a PDF becomes an `image` block with
542
+ * `media_type: application/pdf` that the model cannot read.
543
+ *
544
+ * Validating up front turns all of those into an actionable 400.
545
+ */
546
+ export function findContentBlockProblem(messages: unknown[]): ContentBlockProblem | undefined {
547
+ for (const [index, message] of messages.entries()) {
548
+ const content = isRecord(message) ? message.content : undefined;
549
+ if (!Array.isArray(content)) continue;
550
+ for (const block of content) {
551
+ if (typeof block === 'string') continue;
552
+ const reason = describeContentBlockProblem(block);
553
+ if (reason) return { index, reason };
554
+ }
555
+ }
556
+ return undefined;
557
+ }
558
+
559
+ function describeContentBlockProblem(block: unknown): string | undefined {
560
+ if (!isRecord(block)) return 'each content block must be an object';
561
+
562
+ const type = typeof block.type === 'string' ? block.type : undefined;
563
+ if (!type) return "each content block requires a 'type' field";
564
+ if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
565
+ return (
566
+ `content block type '${type}' is not supported — this gateway forwards 'text' and 'image_url' only. ` +
567
+ `Send documents as text, or inline them as an 'image_url' data URL if the model reads images`
568
+ );
569
+ }
570
+
571
+ if (type === 'text') {
572
+ return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
573
+ }
574
+
575
+ return describeImageUrlProblem(block.image_url);
576
+ }
577
+
578
+ function describeImageUrlProblem(imageUrl: unknown): string | undefined {
579
+ const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
580
+ if (typeof url !== 'string' || url === '') {
581
+ return "an 'image_url' block requires a non-empty 'image_url.url' string";
582
+ }
583
+
584
+ if (url.startsWith('data:')) {
585
+ const match = BASE64_DATA_URL_PATTERN.exec(url);
586
+ if (!match) {
587
+ return (
588
+ `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
589
+ `with standard base64 (no whitespace or URL-safe characters)`
590
+ );
591
+ }
592
+ const mimeType = match[1].toLowerCase();
593
+ if (!mimeType.startsWith('image/')) {
594
+ return (
595
+ `data URL MIME type '${mimeType}' is not an image. Only 'image/*' data URLs are forwarded, ` +
596
+ `because providers reject or ignore other types on an 'image_url' block`
597
+ );
598
+ }
599
+ if (!isDecodableBase64(match[2])) {
600
+ return (
601
+ `base64 payload is not decodable. Check the padding and length — ` +
602
+ `the data must be a multiple of 4 characters with at most two trailing '='`
603
+ );
604
+ }
605
+ return undefined;
606
+ }
607
+
608
+ let protocol: string;
609
+ try {
610
+ protocol = new URL(url).protocol;
611
+ } catch {
612
+ return `'${url}' is not a valid URL. Use an http(s) URL or a base64 data URL`;
613
+ }
614
+ if (protocol !== 'http:' && protocol !== 'https:') {
615
+ return `URL protocol '${protocol}' is not supported. Use an http(s) URL or a base64 data URL`;
616
+ }
617
+ return undefined;
618
+ }
619
+
620
+ /**
621
+ * A LangChain message content value: plain text, or an array of content blocks
622
+ * (`{type:'text'}`, `{type:'image_url'}`, ...) for multimodal requests.
623
+ */
624
+ type MessageContent = string | Record<string, unknown>[];
625
+
626
+ /**
627
+ * Normalize an OpenAI `message.content` into something LangChain accepts.
628
+ *
629
+ * Content blocks are passed through unchanged so vision requests reach the
630
+ * provider intact — `@langchain/core` coerces `image_url` blocks into the
631
+ * provider's native format. Only genuinely unusable shapes (numbers, objects)
632
+ * are stringified as a last resort.
633
+ *
634
+ * The one rewrite is `image_url: '<url>'` → `image_url: { url: '<url>' }`.
635
+ * `isOpenAIDataBlock` gates on `_isObject(block.image_url)`, so the string form
636
+ * is never recognised as a data block: core forwards it untouched and only
637
+ * Anthropic's adapter happens to accept it. Widening it here keeps the lenient
638
+ * request working on every provider instead of just one.
639
+ */
640
+ export function normalizeMessageContent(content: unknown): MessageContent {
641
+ if (typeof content === 'string') return content;
642
+ if (Array.isArray(content)) {
643
+ return content.map((block) => {
644
+ if (typeof block === 'string') return { type: 'text', text: block };
645
+ const record = block as Record<string, unknown>;
646
+ if (record?.type === 'image_url' && typeof record.image_url === 'string') {
647
+ return { ...record, image_url: { url: record.image_url } };
648
+ }
649
+ return record;
650
+ });
651
+ }
652
+ if (content === null || content === undefined) return '';
653
+ return JSON.stringify(content);
654
+ }
655
+
387
656
  const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'tools', 'tool_choice', 'stream', 'n']);
388
657
 
389
658
  export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
@@ -10,9 +10,16 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { generateCompletionId, toOpenAIError, formatSSE, formatSSEDone } from '../utils/openai-format';
12
12
  import { resolveModelString } from '../utils/resolve-service';
13
- import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
13
+ import {
14
+ createRequestAbortController,
15
+ isClientDisconnected,
16
+ isStreamingRequested,
17
+ writeResponse,
18
+ } from '../utils/streaming';
14
19
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
15
20
  import type PluginAiApiServer from '../plugin';
21
+ import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
22
+ import { markAiApiFirstProviderOutput } from '../utils/app-observability';
16
23
 
17
24
  /**
18
25
  * POST /api/ai-llm/v1/completions
@@ -112,6 +119,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
112
119
  return;
113
120
  }
114
121
 
122
+ await prepareLlmBilling(ctx, resolved);
123
+
115
124
  const modelOptions: Record<string, any> = {
116
125
  model: modelId,
117
126
  llmService: service.name,
@@ -154,6 +163,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
154
163
 
155
164
  const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
156
165
  const chatModel = provider.createModel();
166
+ markLlmProviderAttempted(ctx);
157
167
 
158
168
  if (stream) {
159
169
  await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
@@ -163,8 +173,15 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
163
173
  } catch (err) {
164
174
  ctx.log.error('AI API completions error:', err);
165
175
  if (!ctx.res.headersSent) {
166
- ctx.status = 500;
167
- ctx.body = toOpenAIError(500, getErrorMessage(err, 'Internal server error'), 'server_error');
176
+ const isQuotaError = err instanceof AiApiQuotaError;
177
+ ctx.status = isQuotaError ? 429 : 500;
178
+ if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
179
+ ctx.body = toOpenAIError(
180
+ ctx.status,
181
+ getErrorMessage(err, 'Internal server error'),
182
+ isQuotaError ? 'quota_error' : 'server_error',
183
+ isQuotaError ? err.code : undefined,
184
+ );
168
185
  }
169
186
  }
170
187
  }
@@ -246,6 +263,7 @@ async function handleStreamingTextCompletion(
246
263
  }
247
264
 
248
265
  if (text) {
266
+ markAiApiFirstProviderOutput(ctx);
249
267
  await writeResponse(
250
268
  ctx,
251
269
  formatSSE({
@@ -295,6 +313,7 @@ async function handleStreamingTextCompletion(
295
313
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
296
314
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
297
315
  } catch (err) {
316
+ const cancelled = isClientDisconnected(ctx, err);
298
317
  ctx.log.error('AI API completions streaming error:', err);
299
318
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
300
319
  await writeResponse(
@@ -308,7 +327,11 @@ async function handleStreamingTextCompletion(
308
327
  );
309
328
  }
310
329
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
311
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'stream_error' };
330
+ ctx.state.aiApiStreamResult = {
331
+ succeeded: false,
332
+ id: completionId,
333
+ errorCode: cancelled ? 'client_disconnected' : 'stream_error',
334
+ };
312
335
  } finally {
313
336
  requestAbort.dispose();
314
337
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();