plugin-ai-api 1.0.23 → 1.0.24

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.
@@ -39,6 +39,7 @@ interface QuotaPolicy {
39
39
  currency: string;
40
40
  rejectUnpricedModel: boolean;
41
41
  missingUsageBehavior: 'allow' | 'use_reserved';
42
+ contextOverflowBehavior: 'reject' | 'truncate';
42
43
  }
43
44
 
44
45
  export default function UserQuotasPage() {
@@ -85,6 +86,7 @@ export default function UserQuotasPage() {
85
86
  currency: 'USD',
86
87
  rejectUnpricedModel: true,
87
88
  missingUsageBehavior: 'use_reserved',
89
+ contextOverflowBehavior: 'reject',
88
90
  } as QuotaPolicy);
89
91
  setOpen(true);
90
92
  };
@@ -157,6 +159,14 @@ export default function UserQuotasPage() {
157
159
  width: 120,
158
160
  render: (value, record) => (value == null ? t('Unlimited') : `${value} ${record.currency}`),
159
161
  },
162
+ {
163
+ title: t('Context overflow behavior'),
164
+ dataIndex: 'contextOverflowBehavior',
165
+ key: 'contextOverflowBehavior',
166
+ width: 150,
167
+ render: (value: QuotaPolicy['contextOverflowBehavior']) =>
168
+ value === 'truncate' ? t('Truncate oldest conversation turns') : t('Reject request'),
169
+ },
160
170
  { title: t('Timezone'), dataIndex: 'timezone', key: 'timezone', width: 140 },
161
171
  {
162
172
  title: t('Status'),
@@ -248,6 +258,14 @@ export default function UserQuotasPage() {
248
258
  ]}
249
259
  />
250
260
  </Form.Item>
261
+ <Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
262
+ <Select
263
+ options={[
264
+ { label: t('Reject request'), value: 'reject' },
265
+ { label: t('Truncate oldest conversation turns'), value: 'truncate' },
266
+ ]}
267
+ />
268
+ </Form.Item>
251
269
  <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
252
270
  <Switch />
253
271
  </Form.Item>
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "Missing usage behavior",
54
54
  "Use reserved estimate": "Use reserved estimate",
55
55
  "Allow without token charge": "Allow without token charge",
56
+ "Context overflow behavior": "Context overflow behavior",
57
+ "Reject request": "Reject request",
58
+ "Truncate oldest conversation turns": "Truncate oldest conversation turns",
56
59
  "Started at": "Started at",
57
60
  "Requested model": "Requested model",
58
61
  "Resolved service": "Resolved service",
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "Xử lý khi thiếu token usage",
54
54
  "Use reserved estimate": "Dùng số liệu giữ chỗ để ước tính",
55
55
  "Allow without token charge": "Cho phép và không tính token",
56
+ "Context overflow behavior": "Xử lý khi vượt context",
57
+ "Reject request": "Từ chối request",
58
+ "Truncate oldest conversation turns": "Cắt các lượt hội thoại cũ nhất",
56
59
  "Started at": "Bắt đầu lúc",
57
60
  "Requested model": "Model được yêu cầu",
58
61
  "Resolved service": "Service đã resolve",
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "缺少用量时的行为",
54
54
  "Use reserved estimate": "使用预留估算",
55
55
  "Allow without token charge": "允许且不计令牌",
56
+ "Context overflow behavior": "上下文超限处理",
57
+ "Reject request": "拒绝请求",
58
+ "Truncate oldest conversation turns": "截断最早的对话轮次",
56
59
  "Started at": "开始时间",
57
60
  "Requested model": "请求模型",
58
61
  "Resolved service": "解析后的服务",
@@ -0,0 +1,125 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
4
+
5
+ function context({ behavior = 'reject', metadata = { contextWindow: 120, maxCompletionTokens: 40 } } = {}): Context {
6
+ return {
7
+ state: { currentUser: { id: 1 } },
8
+ db: {
9
+ getRepository: vi.fn((name: string) => {
10
+ if (name === 'aiApiModelMetadata') {
11
+ return {
12
+ findOne: vi.fn().mockResolvedValue({ get: (key: string) => metadata[key as keyof typeof metadata] }),
13
+ };
14
+ }
15
+ if (name === 'aiApiUserQuotaPolicies') {
16
+ return {
17
+ findOne: vi
18
+ .fn()
19
+ .mockResolvedValue({ get: (key: string) => (key === 'contextOverflowBehavior' ? behavior : undefined) }),
20
+ };
21
+ }
22
+ return { findOne: vi.fn() };
23
+ }),
24
+ },
25
+ } as unknown as Context;
26
+ }
27
+
28
+ function request(messages: OpenAIMessage[], tools?: unknown) {
29
+ return {
30
+ serviceName: 'test-service',
31
+ modelId: 'test-model',
32
+ messages,
33
+ tools,
34
+ };
35
+ }
36
+
37
+ describe('direct LLM context preparation', () => {
38
+ it('uses reject when the user has no enabled policy', async () => {
39
+ const ctx = context();
40
+ vi.mocked(ctx.db.getRepository).mockImplementation((name: string) => {
41
+ if (name === 'aiApiModelMetadata') {
42
+ return {
43
+ findOne: vi.fn().mockResolvedValue({ get: (key: string) => (key === 'contextWindow' ? 120 : 40) }),
44
+ } as never;
45
+ }
46
+ return { findOne: vi.fn().mockResolvedValue(null) } as never;
47
+ });
48
+
49
+ await expect(
50
+ prepareDirectLlmContext(ctx, request([{ role: 'user', content: 'x'.repeat(400) }])),
51
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
52
+ });
53
+
54
+ it('keeps a request that fits the model input budget', async () => {
55
+ const messages = [{ role: 'user', content: 'hello' }];
56
+ const prepared = await prepareDirectLlmContext(context(), request(messages));
57
+
58
+ expect(prepared.messages).toBe(messages);
59
+ expect(prepared.truncated).toBe(false);
60
+ expect(prepared.inputTokenBudget).toBe(80);
61
+ });
62
+
63
+ it('rejects a requested output limit above model metadata', async () => {
64
+ await expect(
65
+ prepareDirectLlmContext(context(), { ...request([{ role: 'user', content: 'hello' }]), maxCompletionTokens: 41 }),
66
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'max_completion_tokens_exceeds_model_limit' });
67
+ });
68
+
69
+ it('truncates oldest complete turns while preserving instructions and newest turn', async () => {
70
+ const messages: OpenAIMessage[] = [
71
+ { role: 'system', content: 'Always answer safely.' },
72
+ { role: 'user', content: 'first '.repeat(30) },
73
+ { role: 'assistant', content: 'first answer '.repeat(20) },
74
+ { role: 'user', content: 'latest question' },
75
+ ];
76
+
77
+ const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
78
+
79
+ expect(prepared.truncated).toBe(true);
80
+ expect(prepared.messages).toEqual([messages[0], messages[3]]);
81
+ expect(messages).toHaveLength(4);
82
+ });
83
+
84
+ it('keeps assistant tool calls and their tool responses in the same turn', async () => {
85
+ const messages: OpenAIMessage[] = [
86
+ { role: 'user', content: 'old question '.repeat(30) },
87
+ { role: 'assistant', content: '', tool_calls: [{ id: 'call-old', type: 'function' }] },
88
+ { role: 'tool', tool_call_id: 'call-old', content: 'old result '.repeat(20) },
89
+ { role: 'user', content: 'latest question' },
90
+ ];
91
+
92
+ const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
93
+
94
+ expect(prepared.messages).toEqual([messages[3]]);
95
+ });
96
+
97
+ it('rejects when the newest turn cannot fit without cutting content', async () => {
98
+ await expect(
99
+ prepareDirectLlmContext(context({ behavior: 'truncate' }), request([{ role: 'user', content: 'x'.repeat(400) }])),
100
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
101
+ });
102
+
103
+ it('rejects image content until a model-specific estimator is available', async () => {
104
+ await expect(
105
+ prepareDirectLlmContext(
106
+ context(),
107
+ request([
108
+ { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.test/image.png' } }] },
109
+ ]),
110
+ ),
111
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_estimation_unsupported' });
112
+ });
113
+
114
+ it('counts tool definitions as fixed input overhead', async () => {
115
+ await expect(
116
+ prepareDirectLlmContext(
117
+ context(),
118
+ request(
119
+ [{ role: 'user', content: 'hello' }],
120
+ [{ type: 'function', function: { name: 'large', parameters: { text: 'x'.repeat(400) } } }],
121
+ ),
122
+ ),
123
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
124
+ });
125
+ });
@@ -15,18 +15,20 @@ interface ModelResult {
15
15
  usage_metadata?: Record<string, unknown>;
16
16
  }
17
17
 
18
- function createContext(result: ModelResult): Context {
18
+ function createContext(result: ModelResult) {
19
19
  const model = {
20
20
  invoke: vi.fn().mockResolvedValue(result),
21
21
  modelKwargs: {},
22
22
  };
23
+ let providerCreateCount = 0;
23
24
  class TestProvider {
24
25
  createModel() {
26
+ providerCreateCount += 1;
25
27
  return model;
26
28
  }
27
29
  }
28
30
 
29
- return {
31
+ const ctx = {
30
32
  app: {
31
33
  pm: {
32
34
  get: vi.fn().mockReturnValue({
@@ -37,7 +39,16 @@ function createContext(result: ModelResult): Context {
37
39
  },
38
40
  },
39
41
  db: {
40
- getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
42
+ getRepository: vi.fn((name: string) => {
43
+ if (name === 'aiApiModelMetadata') {
44
+ return {
45
+ findOne: vi.fn().mockResolvedValue({
46
+ get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
47
+ }),
48
+ };
49
+ }
50
+ return { findOne: vi.fn().mockResolvedValue(null) };
51
+ }),
41
52
  },
42
53
  log: { error: vi.fn() },
43
54
  request: {
@@ -50,6 +61,8 @@ function createContext(result: ModelResult): Context {
50
61
  state: {},
51
62
  set: vi.fn(),
52
63
  } as unknown as Context;
64
+
65
+ return { ctx, model, getProviderCreateCount: () => providerCreateCount };
53
66
  }
54
67
 
55
68
  class ListenerTarget {
@@ -139,7 +152,16 @@ function createStreamingContext(
139
152
  },
140
153
  },
141
154
  db: {
142
- getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
155
+ getRepository: vi.fn((name: string) => {
156
+ if (name === 'aiApiModelMetadata') {
157
+ return {
158
+ findOne: vi.fn().mockResolvedValue({
159
+ get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
160
+ }),
161
+ };
162
+ }
163
+ return { findOne: vi.fn().mockResolvedValue(null) };
164
+ }),
143
165
  },
144
166
  log: { error: vi.fn() },
145
167
  req,
@@ -174,7 +196,7 @@ describe('AI API chat usage collection', () => {
174
196
  });
175
197
 
176
198
  it('keeps the public zero fallback but marks missing provider usage as unavailable internally', async () => {
177
- const ctx = createContext({ content: 'Hello back' });
199
+ const { ctx } = createContext({ content: 'Hello back' });
178
200
 
179
201
  await handleChatCompletions(ctx, {} as PluginAiApiServer);
180
202
 
@@ -195,7 +217,7 @@ describe('AI API chat usage collection', () => {
195
217
  });
196
218
 
197
219
  it('stores provider usage and provider request ID separately from the gateway response ID', async () => {
198
- const ctx = createContext({
220
+ const { ctx } = createContext({
199
221
  content: 'Hello back',
200
222
  response_metadata: { request_id: 'provider-request-1' },
201
223
  usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
@@ -303,6 +325,31 @@ describe('AI API chat usage collection', () => {
303
325
  );
304
326
  });
305
327
 
328
+ it('rejects context overflow before creating a provider or reserving quota', async () => {
329
+ const { ctx, getProviderCreateCount } = createContext({ content: 'unused' });
330
+ (ctx.request.body as Record<string, unknown>).messages = [{ role: 'user', content: 'x'.repeat(600_000) }];
331
+
332
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
333
+
334
+ expect(ctx.status).toBe(400);
335
+ expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
336
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
337
+ expect(getProviderCreateCount()).toBe(0);
338
+ });
339
+
340
+ it('rejects oversized legacy prompts before creating a provider or reserving quota', async () => {
341
+ const { ctx, model, getProviderCreateCount } = createContext({ content: 'unused' });
342
+ (ctx.request.body as Record<string, unknown>).prompt = 'x'.repeat(600_000);
343
+
344
+ await handleCompletions(ctx, {} as PluginAiApiServer);
345
+
346
+ expect(ctx.status).toBe(400);
347
+ expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
348
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
349
+ expect(getProviderCreateCount()).toBe(0);
350
+ expect(model.invoke).not.toHaveBeenCalled();
351
+ });
352
+
306
353
  it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
307
354
  const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
308
355
 
@@ -0,0 +1,36 @@
1
+ import type { Model } from '@nocobase/database';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('dayjs', () => ({
5
+ default: () => ({ tz: vi.fn() }),
6
+ }));
7
+
8
+ import { validateQuotaPolicy } from '../validation';
9
+
10
+ function quotaPolicy(values: Record<string, unknown>): Model {
11
+ return {
12
+ get: (key: string) => values[key],
13
+ } as Model;
14
+ }
15
+
16
+ const requiredPolicy = {
17
+ periodType: 'monthly',
18
+ missingUsageBehavior: 'use_reserved',
19
+ timezone: 'UTC',
20
+ };
21
+
22
+ describe('AI API quota policy validation', () => {
23
+ it('uses reject when context overflow behavior is absent', () => {
24
+ expect(() => validateQuotaPolicy(quotaPolicy(requiredPolicy))).not.toThrow();
25
+ });
26
+
27
+ it.each(['reject', 'truncate'] as const)('accepts %s context overflow behavior', (contextOverflowBehavior) => {
28
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior }))).not.toThrow();
29
+ });
30
+
31
+ it('rejects an unsupported context overflow behavior', () => {
32
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior: 'compact' }))).toThrow(
33
+ 'contextOverflowBehavior must be reject or truncate.',
34
+ );
35
+ });
36
+ });
@@ -22,6 +22,7 @@ export default defineCollection({
22
22
  { name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
23
23
  { name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
24
24
  { name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
25
+ { name: 'contextOverflowBehavior', type: 'string', allowNull: false, defaultValue: 'reject' },
25
26
  ],
26
27
  indexes: [
27
28
  {
@@ -31,6 +31,7 @@ import { enforceModelAccess } from '../utils/user-permissions';
31
31
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
32
32
  import type PluginAiApiServer from '../plugin';
33
33
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
34
+ import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
34
35
  import { markAiApiFirstProviderOutput } from '../utils/app-observability';
35
36
 
36
37
  /**
@@ -142,8 +143,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
142
143
  return;
143
144
  }
144
145
 
145
- await prepareLlmBilling(ctx, resolved);
146
-
147
146
  const providerRequestParameters = getProviderRequestParameters(body);
148
147
  if (stream) {
149
148
  const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
@@ -166,13 +165,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
166
165
  if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
167
166
  if (body.stop !== undefined) modelOptions.stop = body.stop;
168
167
 
169
- const Provider = providerMeta.provider;
170
- const provider = new Provider({
171
- app: ctx.app,
172
- serviceOptions: service.options,
173
- modelOptions,
174
- });
175
-
176
168
  // ─── Build system prompt from AI Employee ───
177
169
  let systemPrompt = '';
178
170
  if (config?.defaultAiEmployee) {
@@ -197,12 +189,31 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
197
189
  }
198
190
 
199
191
  // ─── Build messages (inject system prompt if not provided by client) ───
200
- const messages = [...body.messages];
192
+ let messages: OpenAIMessage[] = [...body.messages];
201
193
  const hasSystemMessage = messages.some((m: any) => m.role === 'system');
202
194
  if (systemPrompt && !hasSystemMessage) {
203
195
  messages.unshift({ role: 'system', content: systemPrompt });
204
196
  }
205
197
 
198
+ const preparedContext = await prepareDirectLlmContext(ctx, {
199
+ serviceName: service.name,
200
+ modelId,
201
+ messages,
202
+ tools: body.tools,
203
+ maxCompletionTokens: body.max_completion_tokens,
204
+ maxTokens: body.max_tokens,
205
+ });
206
+ messages = preparedContext.messages;
207
+
208
+ await prepareLlmBilling(ctx, resolved);
209
+
210
+ const Provider = providerMeta.provider;
211
+ const provider = new Provider({
212
+ app: ctx.app,
213
+ serviceOptions: service.options,
214
+ modelOptions,
215
+ });
216
+
206
217
  // ─── Build message tuples for LangChain model ───
207
218
  // LangChain chat models accept [role, content] tuples or BaseMessage objects.
208
219
  // We use tuples to avoid importing @langchain/core directly.
@@ -257,15 +268,16 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
257
268
  }
258
269
  } catch (err) {
259
270
  ctx.log.error('AI API chat completions error:', err);
260
- if (!ctx.res.headersSent) {
271
+ if (!ctx.res?.headersSent) {
261
272
  const isQuotaError = err instanceof AiApiQuotaError;
262
- ctx.status = isQuotaError ? 429 : 500;
273
+ const isContextError = err instanceof DirectLlmContextError;
274
+ ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
263
275
  if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
264
276
  ctx.body = toOpenAIError(
265
277
  ctx.status,
266
278
  getErrorMessage(err, 'Internal server error'),
267
- isQuotaError ? 'quota_error' : 'server_error',
268
- isQuotaError ? err.code : undefined,
279
+ isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
280
+ isQuotaError || isContextError ? err.code : undefined,
269
281
  );
270
282
  }
271
283
  }
@@ -26,6 +26,7 @@ import {
26
26
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
27
27
  import type PluginAiApiServer from '../plugin';
28
28
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
29
+ import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
29
30
  import { markAiApiFirstProviderOutput } from '../utils/app-observability';
30
31
 
31
32
  /**
@@ -114,8 +115,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
114
115
  return;
115
116
  }
116
117
 
117
- await prepareLlmBilling(ctx, resolved);
118
-
119
118
  const modelOptions: Record<string, any> = {
120
119
  model: modelId,
121
120
  llmService: service.name,
@@ -126,13 +125,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
126
125
  if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
127
126
  if (body.stop !== undefined) modelOptions.stop = body.stop;
128
127
 
129
- const Provider = providerMeta.provider;
130
- const provider = new Provider({
131
- app: ctx.app,
132
- serviceOptions: service.options,
133
- modelOptions,
134
- });
135
-
136
128
  // ─── Convert prompt to message tuple ───
137
129
  const prompt =
138
130
  typeof body.prompt === 'string'
@@ -142,7 +134,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
142
134
  : String(body.prompt);
143
135
 
144
136
  // Inject system prompt from AI Employee if configured
145
- const langchainMessages: [string, string][] = [];
137
+ const messages: OpenAIMessage[] = [];
146
138
  if (config?.defaultAiEmployee) {
147
139
  const employee = await ctx.db.getRepository('aiEmployees').findOne({
148
140
  filter: { username: config.defaultAiEmployee },
@@ -150,11 +142,30 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
150
142
  if (employee) {
151
143
  const systemPrompt = employee.about || employee.defaultPrompt || '';
152
144
  if (systemPrompt) {
153
- langchainMessages.push(['system', systemPrompt]);
145
+ messages.push({ role: 'system', content: systemPrompt });
154
146
  }
155
147
  }
156
148
  }
157
- langchainMessages.push(['human', prompt]);
149
+ messages.push({ role: 'user', content: prompt });
150
+
151
+ const preparedContext = await prepareDirectLlmContext(ctx, {
152
+ serviceName: service.name,
153
+ modelId,
154
+ messages,
155
+ maxTokens: body.max_tokens,
156
+ });
157
+ await prepareLlmBilling(ctx, resolved);
158
+
159
+ const Provider = providerMeta.provider;
160
+ const provider = new Provider({
161
+ app: ctx.app,
162
+ serviceOptions: service.options,
163
+ modelOptions,
164
+ });
165
+ const langchainMessages = preparedContext.messages.map((message): [string, string] => [
166
+ message.role === 'user' ? 'human' : message.role,
167
+ String(message.content ?? ''),
168
+ ]);
158
169
 
159
170
  const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
160
171
  const chatModel = provider.createModel();
@@ -174,15 +185,16 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
174
185
  }
175
186
  } catch (err) {
176
187
  ctx.log.error('AI API completions error:', err);
177
- if (!ctx.res.headersSent) {
188
+ if (!ctx.res?.headersSent) {
178
189
  const isQuotaError = err instanceof AiApiQuotaError;
179
- ctx.status = isQuotaError ? 429 : 500;
190
+ const isContextError = err instanceof DirectLlmContextError;
191
+ ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
180
192
  if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
181
193
  ctx.body = toOpenAIError(
182
194
  ctx.status,
183
195
  getErrorMessage(err, 'Internal server error'),
184
- isQuotaError ? 'quota_error' : 'server_error',
185
- isQuotaError ? err.code : undefined,
196
+ isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
197
+ isQuotaError || isContextError ? err.code : undefined,
186
198
  );
187
199
  }
188
200
  }