@moxxy/plugin-provider-openai 0.1.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.
@@ -0,0 +1,350 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { OpenAIProvider } from './provider.js';
3
+
4
+ function fakeOpenAI(chunks: ReadonlyArray<unknown>): { chat: { completions: { create: () => Promise<AsyncIterable<unknown>> } } } {
5
+ return {
6
+ chat: {
7
+ completions: {
8
+ create: async () =>
9
+ (async function* () {
10
+ for (const c of chunks) yield c;
11
+ })(),
12
+ },
13
+ },
14
+ };
15
+ }
16
+
17
+ describe('OpenAIProvider.stream', () => {
18
+ it('emits message_start, text_delta(s), message_end for a plain text completion', async () => {
19
+ const fake = fakeOpenAI([
20
+ { choices: [{ index: 0, delta: { content: 'Hello, ' } }] },
21
+ { choices: [{ index: 0, delta: { content: 'world!' } }] },
22
+ { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
23
+ ]);
24
+ const p = new OpenAIProvider({ client: fake as never });
25
+ const events = [];
26
+ for await (const e of p.stream({ model: 'gpt-4o-mini', messages: [] })) events.push(e);
27
+ expect(events[0]).toMatchObject({ type: 'message_start' });
28
+ const text = events.filter((e) => e.type === 'text_delta').map((e) => (e as { delta: string }).delta).join('');
29
+ expect(text).toBe('Hello, world!');
30
+ expect(events[events.length - 1]).toMatchObject({ type: 'message_end', stopReason: 'end_turn' });
31
+ });
32
+
33
+ it('translates streamed tool_calls into tool_use_start / _delta / _end', async () => {
34
+ const fake = fakeOpenAI([
35
+ {
36
+ choices: [
37
+ {
38
+ index: 0,
39
+ delta: {
40
+ tool_calls: [
41
+ { index: 0, id: 'call_1', function: { name: 'Read', arguments: '' } },
42
+ ],
43
+ },
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ choices: [
49
+ {
50
+ index: 0,
51
+ delta: { tool_calls: [{ index: 0, function: { arguments: '{"path":' } }] },
52
+ },
53
+ ],
54
+ },
55
+ {
56
+ choices: [
57
+ {
58
+ index: 0,
59
+ delta: { tool_calls: [{ index: 0, function: { arguments: '"/etc/hosts"}' } }] },
60
+ },
61
+ ],
62
+ },
63
+ { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
64
+ ]);
65
+ const p = new OpenAIProvider({ client: fake as never });
66
+ const events = [];
67
+ for await (const e of p.stream({ model: 'gpt-4o-mini', messages: [] })) events.push(e);
68
+ const start = events.find((e) => e.type === 'tool_use_start');
69
+ expect(start).toMatchObject({ id: 'call_1', name: 'Read' });
70
+ const end = events.find((e) => e.type === 'tool_use_end');
71
+ expect(end).toMatchObject({ id: 'call_1', input: { path: '/etc/hosts' } });
72
+ const last = events[events.length - 1];
73
+ expect(last).toMatchObject({ type: 'message_end', stopReason: 'tool_use' });
74
+ });
75
+
76
+ it('maps OpenAI finish_reason values to moxxy stop reasons', async () => {
77
+ const cases: Array<[string, string]> = [
78
+ ['stop', 'end_turn'],
79
+ ['length', 'max_tokens'],
80
+ ['tool_calls', 'tool_use'],
81
+ ['content_filter', 'error'],
82
+ ];
83
+ for (const [reason, expected] of cases) {
84
+ const fake = fakeOpenAI([{ choices: [{ delta: {}, finish_reason: reason }] }]);
85
+ const p = new OpenAIProvider({ client: fake as never });
86
+ const events = [];
87
+ for await (const e of p.stream({ model: 'gpt-4o', messages: [] })) events.push(e);
88
+ expect(events[events.length - 1]).toMatchObject({ type: 'message_end', stopReason: expected });
89
+ }
90
+ });
91
+
92
+ it('requests usage via stream_options and surfaces token + cache-read counts from the final empty-choices chunk', async () => {
93
+ let captured: Record<string, unknown> | undefined;
94
+ const fake = {
95
+ chat: {
96
+ completions: {
97
+ create: async (body: Record<string, unknown>) => {
98
+ captured = body;
99
+ return (async function* () {
100
+ yield { choices: [{ index: 0, delta: { content: 'hi' } }] };
101
+ yield { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
102
+ // OpenAI sends usage in a trailing chunk with NO choices, only
103
+ // when include_usage was requested.
104
+ yield {
105
+ choices: [],
106
+ usage: {
107
+ prompt_tokens: 100,
108
+ completion_tokens: 20,
109
+ prompt_tokens_details: { cached_tokens: 80 },
110
+ },
111
+ };
112
+ })();
113
+ },
114
+ },
115
+ },
116
+ };
117
+ const p = new OpenAIProvider({ client: fake as never });
118
+ const events = [];
119
+ for await (const e of p.stream({ model: 'gpt-4o-mini', messages: [] })) events.push(e);
120
+ expect(captured?.stream_options).toEqual({ include_usage: true });
121
+ expect(events[events.length - 1]).toMatchObject({
122
+ type: 'message_end',
123
+ usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 80 },
124
+ });
125
+ });
126
+
127
+ it('emits error event when create() throws', async () => {
128
+ const fake = {
129
+ chat: {
130
+ completions: {
131
+ create: async () => {
132
+ throw new Error('rate_limit');
133
+ },
134
+ },
135
+ },
136
+ };
137
+ const p = new OpenAIProvider({ client: fake as never });
138
+ const events = [];
139
+ for await (const e of p.stream({ model: 'gpt-4o', messages: [] })) events.push(e);
140
+ const err = events.find((e) => e.type === 'error');
141
+ expect(err).toMatchObject({ retryable: true });
142
+ });
143
+
144
+ it('emits reasoning_delta from delta.reasoning_content when req.reasoning is set', async () => {
145
+ const fake = fakeOpenAI([
146
+ { choices: [{ index: 0, delta: { reasoning_content: 'thinking… ' } }] },
147
+ { choices: [{ index: 0, delta: { reasoning_content: 'done' } }] },
148
+ { choices: [{ index: 0, delta: { content: 'answer' } }] },
149
+ { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
150
+ ]);
151
+ const p = new OpenAIProvider({ client: fake as never });
152
+ const events = [];
153
+ for await (const e of p.stream({
154
+ model: 'gpt-5.4-mini',
155
+ messages: [],
156
+ reasoning: { effort: 'low' },
157
+ })) {
158
+ events.push(e);
159
+ }
160
+ const reasoning = events
161
+ .filter((e) => e.type === 'reasoning_delta')
162
+ .map((e) => (e as { delta: string }).delta)
163
+ .join('');
164
+ expect(reasoning).toBe('thinking… done');
165
+ });
166
+
167
+ it('handles the alternate delta.reasoning field name', async () => {
168
+ const fake = fakeOpenAI([
169
+ { choices: [{ index: 0, delta: { reasoning: 'alt-field' } }] },
170
+ { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
171
+ ]);
172
+ const p = new OpenAIProvider({ client: fake as never });
173
+ const events = [];
174
+ for await (const e of p.stream({ model: 'gpt-5.4-mini', messages: [], reasoning: true })) {
175
+ events.push(e);
176
+ }
177
+ const reasoning = events.find((e) => e.type === 'reasoning_delta');
178
+ expect(reasoning).toMatchObject({ delta: 'alt-field' });
179
+ });
180
+
181
+ it('ignores reasoning deltas when req.reasoning is absent or false', async () => {
182
+ for (const reasoning of [undefined, false]) {
183
+ const fake = fakeOpenAI([
184
+ { choices: [{ index: 0, delta: { reasoning_content: 'should be dropped' } }] },
185
+ { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
186
+ ]);
187
+ const p = new OpenAIProvider({ client: fake as never });
188
+ const events = [];
189
+ for await (const e of p.stream({ model: 'gpt-5.4-mini', messages: [], reasoning })) {
190
+ events.push(e);
191
+ }
192
+ expect(events.some((e) => e.type === 'reasoning_delta')).toBe(false);
193
+ }
194
+ });
195
+
196
+ it('requests reasoning_effort for reasoning models when reasoning is enabled', async () => {
197
+ let captured: Record<string, unknown> | undefined;
198
+ const fake = {
199
+ chat: {
200
+ completions: {
201
+ create: async (body: Record<string, unknown>) => {
202
+ captured = body;
203
+ return (async function* () {
204
+ yield { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
205
+ })();
206
+ },
207
+ },
208
+ },
209
+ };
210
+ const p = new OpenAIProvider({ client: fake as never });
211
+ const events = [];
212
+ for await (const e of p.stream({
213
+ model: 'gpt-5.4-mini',
214
+ messages: [],
215
+ reasoning: { effort: 'high' },
216
+ })) {
217
+ events.push(e);
218
+ }
219
+ expect(captured?.reasoning_effort).toBe('high');
220
+ });
221
+
222
+ it('requests reasoning_effort for OpenAI-compatible reasoning backends (non-gpt-5 ids)', async () => {
223
+ // z.ai GLM / DeepSeek-R1 / vLLM / Ollama reasoning model ids never match
224
+ // the gpt-5/o1/o3 token-field heuristic, but they honor reasoning_effort —
225
+ // effort must be sent independently of that heuristic.
226
+ let captured: Record<string, unknown> | undefined;
227
+ const fake = {
228
+ chat: {
229
+ completions: {
230
+ create: async (body: Record<string, unknown>) => {
231
+ captured = body;
232
+ return (async function* () {
233
+ yield { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
234
+ })();
235
+ },
236
+ },
237
+ },
238
+ };
239
+ const p = new OpenAIProvider({ client: fake as never });
240
+ for await (const _ of p.stream({
241
+ model: 'glm-4.6',
242
+ messages: [],
243
+ reasoning: { effort: 'high' },
244
+ })) {
245
+ // drain
246
+ }
247
+ expect(captured?.reasoning_effort).toBe('high');
248
+ // The token-field heuristic is unchanged: a non-gpt-5 id keeps `max_tokens`.
249
+ expect(captured && 'max_completion_tokens' in captured).toBe(false);
250
+ });
251
+
252
+ it('emits a clean aborted error (not a classified error) when the signal fires mid-stream', async () => {
253
+ const controller = new AbortController();
254
+ const fake = {
255
+ chat: {
256
+ completions: {
257
+ create: async () =>
258
+ (async function* () {
259
+ yield { choices: [{ index: 0, delta: { content: 'partial' } }] };
260
+ // The host cancels; the SDK rejects the iterator with an AbortError.
261
+ controller.abort();
262
+ throw Object.assign(new Error('Request was aborted'), { name: 'AbortError' });
263
+ })(),
264
+ },
265
+ },
266
+ };
267
+ const p = new OpenAIProvider({ client: fake as never });
268
+ const events = [];
269
+ for await (const e of p.stream({ model: 'gpt-4o', messages: [], signal: controller.signal })) {
270
+ events.push(e);
271
+ }
272
+ const err = events.find((e) => e.type === 'error');
273
+ expect(err).toMatchObject({ message: 'aborted', retryable: false });
274
+ });
275
+
276
+ it('emits a clean aborted error when create() rejects after the signal fired', async () => {
277
+ const controller = new AbortController();
278
+ controller.abort();
279
+ const fake = {
280
+ chat: {
281
+ completions: {
282
+ create: async () => {
283
+ throw Object.assign(new Error('Request was aborted'), { name: 'AbortError' });
284
+ },
285
+ },
286
+ },
287
+ };
288
+ const p = new OpenAIProvider({ client: fake as never });
289
+ const events = [];
290
+ for await (const e of p.stream({ model: 'gpt-4o', messages: [], signal: controller.signal })) {
291
+ events.push(e);
292
+ }
293
+ const err = events.find((e) => e.type === 'error');
294
+ expect(err).toMatchObject({ message: 'aborted', retryable: false });
295
+ });
296
+
297
+ it('countTokens returns a positive estimate', async () => {
298
+ const p = new OpenAIProvider({ client: fakeOpenAI([]) as never });
299
+ const n = await p.countTokens({
300
+ model: 'gpt-4o',
301
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'hello world' }] }],
302
+ });
303
+ expect(n).toBeGreaterThan(0);
304
+ });
305
+
306
+ it('delivers hook-injected req.system as a system message after the leading system prompt', async () => {
307
+ let captured: { messages?: Array<{ role: string; content?: unknown }> } | undefined;
308
+ const fake = {
309
+ chat: {
310
+ completions: {
311
+ create: async (body: never) => {
312
+ captured = body;
313
+ return (async function* () {
314
+ yield { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
315
+ })();
316
+ },
317
+ },
318
+ },
319
+ };
320
+ const p = new OpenAIProvider({ client: fake as never });
321
+ const events = [];
322
+ for await (const e of p.stream({
323
+ model: 'gpt-4o',
324
+ system: '[memory note] consider consolidating',
325
+ messages: [
326
+ { role: 'system', content: [{ type: 'text', text: 'BASE PROMPT' }] },
327
+ { role: 'user', content: [{ type: 'text', text: 'hi' }] },
328
+ ],
329
+ })) {
330
+ events.push(e);
331
+ }
332
+ expect(events.some((e) => e.type === 'error')).toBe(false);
333
+ expect(captured?.messages?.map((m) => m.role)).toEqual(['system', 'system', 'user']);
334
+ expect(captured?.messages?.[0]).toMatchObject({ content: 'BASE PROMPT' });
335
+ expect(captured?.messages?.[1]).toMatchObject({ content: '[memory note] consider consolidating' });
336
+ });
337
+
338
+ it('reports an overridden name + model catalog for runtime-registered vendors', () => {
339
+ const models = [
340
+ { id: 'glm-4.6', contextWindow: 200_000, supportsTools: true, supportsStreaming: true, supportsDocuments: true },
341
+ ];
342
+ const p = new OpenAIProvider({ client: fakeOpenAI([]) as never, name: 'zai', models });
343
+ expect(p.name).toBe('zai');
344
+ expect(p.models).toEqual(models);
345
+ // Defaults stay 'openai' + the OpenAI catalog.
346
+ const plain = new OpenAIProvider({ client: fakeOpenAI([]) as never });
347
+ expect(plain.name).toBe('openai');
348
+ expect(plain.models.length).toBeGreaterThan(0);
349
+ });
350
+ });
@@ -0,0 +1,339 @@
1
+ import OpenAI from 'openai';
2
+ import type {
3
+ LLMProvider,
4
+ ModelDescriptor,
5
+ ProviderEvent,
6
+ ProviderRequest,
7
+ StopReason,
8
+ } from '@moxxy/sdk';
9
+ import { estimateTextTokens, toFriendlyError } from '@moxxy/sdk';
10
+ import { toOpenAIMessages, toOpenAITools } from './translate.js';
11
+
12
+ export interface OpenAIProviderConfig {
13
+ readonly apiKey?: string;
14
+ readonly baseURL?: string;
15
+ readonly defaultModel?: string;
16
+ readonly client?: OpenAI;
17
+ /**
18
+ * Override the reported provider name (events, usage stats, error
19
+ * context). Defaults to `'openai'`. Runtime-registered OpenAI-compatible
20
+ * vendors (provider_add → z.ai, deepseek, groq, …) reuse this class and
21
+ * MUST pass their own slug here, otherwise their traffic and errors are
22
+ * all misattributed to OpenAI.
23
+ */
24
+ readonly name?: string;
25
+ /**
26
+ * Override the advertised model catalog. Defaults to the OpenAI catalog.
27
+ * Runtime-registered vendors pass their own descriptors so context-window
28
+ * lookups (compaction/elision budgets) and capability gating
29
+ * (supportsImages/supportsDocuments) work against the vendor's models
30
+ * instead of missing on the OpenAI list.
31
+ */
32
+ readonly models?: ReadonlyArray<ModelDescriptor>;
33
+ }
34
+
35
+ /**
36
+ * Model catalog as of OpenAI's 2026 API surface. The 5.x family supersedes
37
+ * the 4o family but the older ones stay listed so existing configs keep
38
+ * working without a forced migration.
39
+ *
40
+ * Output/context numbers are the public documented limits as of April-May
41
+ * 2026; verify against https://developers.openai.com/api/docs/models when
42
+ * picking a model for a long-context workload.
43
+ */
44
+ // The gpt-5.x family are reasoning models (`supportsReasoning`): they accept
45
+ // `reasoning_effort` and, on backends that stream a summary, surface reasoning
46
+ // deltas. Official OpenAI Chat Completions doesn't stream a reasoning summary
47
+ // (Responses-API only), so the flag mainly gates the config UI + effort there;
48
+ // OpenAI-compatible reasoning backends (DeepSeek/z.ai/local) do stream it.
49
+ export const openAIModels: ReadonlyArray<ModelDescriptor> = [
50
+ // GPT-5.5 family (released April 23, 2026): newest frontier class.
51
+ { id: 'gpt-5.5', contextWindow: 1_050_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
52
+ { id: 'gpt-5.5-pro', contextWindow: 1_050_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
53
+
54
+ // GPT-5.4 family: cheaper general-purpose tier; -mini and -nano are the
55
+ // new sweet-spot defaults for high-volume agentic workloads.
56
+ { id: 'gpt-5.4', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
57
+ { id: 'gpt-5.4-pro', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
58
+ { id: 'gpt-5.4-mini', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
59
+ { id: 'gpt-5.4-nano', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
60
+
61
+ // GPT-5.3-Codex: agentic coding specialist. Vision-capable.
62
+ { id: 'gpt-5.3-codex', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
63
+
64
+ // GPT-5.2 and GPT-5: prior reasoning models, configurable effort.
65
+ { id: 'gpt-5.2', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
66
+ { id: 'gpt-5', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
67
+
68
+ // GPT-4 family: kept for explicit-pin use cases.
69
+ // 4.1 is text-only; 4o/4o-mini are vision + document capable; 4-turbo predates file inputs.
70
+ { id: 'gpt-4.1', contextWindow: 1_000_000, maxOutputTokens: 32_768, supportsTools: true, supportsStreaming: true },
71
+ { id: 'gpt-4o', contextWindow: 128_000, maxOutputTokens: 16_384, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true },
72
+ { id: 'gpt-4o-mini', contextWindow: 128_000, maxOutputTokens: 16_384, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true },
73
+ { id: 'gpt-4-turbo', contextWindow: 128_000, maxOutputTokens: 4_096, supportsTools: true, supportsStreaming: true, supportsImages: true },
74
+ ];
75
+
76
+ interface PendingToolCall {
77
+ id: string;
78
+ name: string;
79
+ argsBuffer: string;
80
+ emittedStart: boolean;
81
+ }
82
+
83
+ export class OpenAIProvider implements LLMProvider {
84
+ readonly name: string;
85
+ readonly models: ReadonlyArray<ModelDescriptor>;
86
+ private readonly client: OpenAI;
87
+ private readonly defaultModel: string;
88
+
89
+ constructor(config: OpenAIProviderConfig = {}) {
90
+ this.name = config.name ?? 'openai';
91
+ this.models = config.models ?? openAIModels;
92
+ this.client =
93
+ config.client ??
94
+ new OpenAI({
95
+ apiKey: config.apiKey ?? process.env.OPENAI_API_KEY,
96
+ ...(config.baseURL ? { baseURL: config.baseURL } : {}),
97
+ });
98
+ this.defaultModel = config.defaultModel ?? 'gpt-5.4-mini';
99
+ }
100
+
101
+ async *stream(req: ProviderRequest): AsyncIterable<ProviderEvent> {
102
+ const messages = toOpenAIMessages(req.messages);
103
+ // `req.system` is the hook-injection side channel (e.g. the memory
104
+ // consolidation nudge): extra system text delivered IN ADDITION to any
105
+ // system-role messages already in `req.messages`. Insert it right after
106
+ // the leading system message(s) so it reads as system guidance without
107
+ // reordering the conversation.
108
+ if (req.system) {
109
+ let insertAt = 0;
110
+ while (insertAt < messages.length && messages[insertAt]!.role === 'system') insertAt += 1;
111
+ messages.splice(insertAt, 0, { role: 'system', content: req.system });
112
+ }
113
+ const tools = req.tools && req.tools.length > 0 ? toOpenAITools(req.tools) : undefined;
114
+ const model = req.model || this.defaultModel;
115
+
116
+ yield { type: 'message_start', model };
117
+
118
+ // GPT-5.x (and OpenAI's reasoning models) renamed the token cap field
119
+ // from `max_tokens` to `max_completion_tokens` and ALSO reject the
120
+ // legacy name with a 400. Use the new name for any model whose id
121
+ // starts with gpt-5 / o1 / o3; keep the legacy name for the gpt-4
122
+ // family so existing callers don't regress.
123
+ const usesCompletionTokens = /^(?:gpt-5|o1|o3)/.test(model);
124
+ const tokenLimitKey = usesCompletionTokens ? 'max_completion_tokens' : 'max_tokens';
125
+
126
+ // Reasoning preview is gated by the per-provider toggle (`req.reasoning`).
127
+ // When on, request `reasoning_effort` for OpenAI reasoning models (improves
128
+ // depth + makes a summary available where the backend streams one) and
129
+ // surface the streamed reasoning/reasoning_content deltas.
130
+ const emitReasoning = req.reasoning != null && req.reasoning !== false;
131
+ const reasoningEffort = typeof req.reasoning === 'object' ? req.reasoning.effort : undefined;
132
+
133
+ // Type the request body as the SDK's streaming-create params so field
134
+ // names/value types are checked. The local `OpenAIChatMessage` /
135
+ // `OpenAIToolDef` shapes genuinely diverge from the SDK's wide message/tool
136
+ // unions (we build a narrower, hand-rolled shape), so cast ONLY those two
137
+ // fields — not the whole body — keeping the rest type-checked.
138
+ const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
139
+ model,
140
+ messages: messages as unknown as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
141
+ ...(tools
142
+ ? { tools: tools as unknown as OpenAI.Chat.Completions.ChatCompletionTool[] }
143
+ : {}),
144
+ ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
145
+ ...(req.maxTokens ? { [tokenLimitKey]: req.maxTokens } : {}),
146
+ // Send `reasoning_effort` independently of the token-field heuristic.
147
+ // The two are unrelated concerns: `usesCompletionTokens` picks the cap
148
+ // FIELD NAME for the OpenAI-hosted reasoning models, while effort applies
149
+ // to any reasoning backend. OpenAI-compatible vendors (z.ai GLM,
150
+ // DeepSeek-R1, vLLM, Ollama) honor reasoning_effort but their model ids
151
+ // never match the gpt-5/o1/o3 regex, so gating effort on it silently
152
+ // dropped a user-requested effort for exactly those backends. The
153
+ // descriptor's `supportsReasoning` already gates this upstream via
154
+ // req.reasoning.
155
+ ...(emitReasoning && reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
156
+ stream: true,
157
+ // OpenAI only emits the final `usage` chunk when this is set;
158
+ // without it `raw.usage` is null on every chunk and token usage
159
+ // (and cache-read counts) are silently lost for every streamed turn.
160
+ stream_options: { include_usage: true },
161
+ };
162
+
163
+ let stream: AsyncIterable<unknown>;
164
+ try {
165
+ stream = (await this.client.chat.completions.create(
166
+ params,
167
+ // Pass the AbortSignal into the SDK request options so cancelling
168
+ // mid-stream tears down the underlying HTTP request instead of just
169
+ // stopping our consumption loop. Without this, Esc / Ctrl+C felt
170
+ // like nothing happened — the model kept generating and the user
171
+ // got charged for tokens after the cancel.
172
+ req.signal ? { signal: req.signal } : undefined,
173
+ )) as unknown as AsyncIterable<unknown>;
174
+ } catch (err) {
175
+ // A cancel can surface as a thrown AbortError from the create() await —
176
+ // report the clean terminal 'aborted' event (parity with the Anthropic
177
+ // provider) so callers that suppress error UI on user cancel don't get a
178
+ // noisy classified provider error.
179
+ if (req.signal?.aborted) {
180
+ yield { type: 'error', message: 'aborted', retryable: false };
181
+ return;
182
+ }
183
+ yield { type: 'error', ...toFriendlyError(err, { provider: this.name }) };
184
+ return;
185
+ }
186
+
187
+ const pending = new Map<number, PendingToolCall>();
188
+ let stopReason: StopReason = 'end_turn';
189
+ let usageIn = 0;
190
+ let usageOut = 0;
191
+ let usageCacheRead = 0;
192
+
193
+ try {
194
+ for await (const raw of stream as AsyncIterable<OpenAIStreamChunk>) {
195
+ if (req.signal?.aborted) {
196
+ yield { type: 'error', message: 'aborted', retryable: false };
197
+ return;
198
+ }
199
+ // Usage arrives in a FINAL chunk that has an empty `choices` array
200
+ // (only when stream_options.include_usage is set), so it must be read
201
+ // before the `!choice` guard below — otherwise it's `continue`d past.
202
+ if (raw.usage) {
203
+ usageIn = raw.usage.prompt_tokens ?? usageIn;
204
+ usageOut = raw.usage.completion_tokens ?? usageOut;
205
+ // `prompt_tokens` already includes the cached portion; surface the
206
+ // cached count so cache hit-rate accounting works (parity with the
207
+ // Anthropic provider's cache_read_input_tokens).
208
+ usageCacheRead = raw.usage.prompt_tokens_details?.cached_tokens ?? usageCacheRead;
209
+ }
210
+ const choice = raw.choices?.[0];
211
+ if (!choice) continue;
212
+ const delta = choice.delta ?? {};
213
+
214
+ if (typeof delta.content === 'string' && delta.content) {
215
+ yield { type: 'text_delta', delta: delta.content };
216
+ }
217
+
218
+ if (emitReasoning) {
219
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
220
+ if (typeof reasoning === 'string' && reasoning) {
221
+ yield { type: 'reasoning_delta', delta: reasoning };
222
+ }
223
+ }
224
+
225
+ if (delta.tool_calls) {
226
+ for (const tcDelta of delta.tool_calls) {
227
+ const idx = tcDelta.index ?? 0;
228
+ let entry = pending.get(idx);
229
+ if (!entry) {
230
+ entry = {
231
+ id: tcDelta.id ?? `call_${idx}`,
232
+ name: tcDelta.function?.name ?? '',
233
+ argsBuffer: '',
234
+ emittedStart: false,
235
+ };
236
+ pending.set(idx, entry);
237
+ } else if (tcDelta.id) {
238
+ entry.id = tcDelta.id;
239
+ }
240
+ if (tcDelta.function?.name && !entry.name) entry.name = tcDelta.function.name;
241
+ if (tcDelta.function?.name && !entry.emittedStart && entry.name) {
242
+ entry.emittedStart = true;
243
+ yield { type: 'tool_use_start', id: entry.id, name: entry.name };
244
+ }
245
+ if (typeof tcDelta.function?.arguments === 'string') {
246
+ entry.argsBuffer += tcDelta.function.arguments;
247
+ yield { type: 'tool_use_delta', id: entry.id, partialInput: tcDelta.function.arguments };
248
+ }
249
+ }
250
+ }
251
+
252
+ if (choice.finish_reason) {
253
+ stopReason = mapStopReason(choice.finish_reason);
254
+ }
255
+ }
256
+ } catch (err) {
257
+ // The SDK rejects the iterator with an AbortError once req.signal fires;
258
+ // emit the clean 'aborted' event instead of a classified provider error
259
+ // (parity with the Anthropic provider and the in-loop abort check above).
260
+ if (req.signal?.aborted) {
261
+ yield { type: 'error', message: 'aborted', retryable: false };
262
+ return;
263
+ }
264
+ yield { type: 'error', ...toFriendlyError(err, { provider: this.name }) };
265
+ return;
266
+ }
267
+
268
+ // Flush tool_use_end events with parsed arguments.
269
+ for (const entry of pending.values()) {
270
+ let parsed: unknown = {};
271
+ if (entry.argsBuffer) {
272
+ try {
273
+ parsed = JSON.parse(entry.argsBuffer);
274
+ } catch {
275
+ parsed = { _rawPartial: entry.argsBuffer };
276
+ }
277
+ }
278
+ if (entry.emittedStart) {
279
+ yield { type: 'tool_use_end', id: entry.id, input: parsed };
280
+ }
281
+ }
282
+
283
+ yield {
284
+ type: 'message_end',
285
+ stopReason,
286
+ usage:
287
+ usageIn > 0 || usageOut > 0
288
+ ? {
289
+ inputTokens: usageIn,
290
+ outputTokens: usageOut,
291
+ ...(usageCacheRead > 0 ? { cacheReadTokens: usageCacheRead } : {}),
292
+ }
293
+ : undefined,
294
+ };
295
+ }
296
+
297
+ async countTokens(req: Pick<ProviderRequest, 'model' | 'messages' | 'system' | 'tools'>): Promise<number> {
298
+ // OpenAI doesn't expose a free token counter; fall back to a coarse estimate.
299
+ const blob =
300
+ (req.system ?? '') +
301
+ req.messages.map((m) => m.content.map((c) => ('text' in c ? c.text : JSON.stringify(c))).join('')).join('') +
302
+ (req.tools ?? []).map((t) => t.name + t.description).join('');
303
+ return estimateTextTokens(blob);
304
+ }
305
+ }
306
+
307
+ interface OpenAIStreamChunk {
308
+ choices?: Array<{
309
+ index?: number;
310
+ delta?: {
311
+ content?: string | null;
312
+ // Reasoning summary streamed by OpenAI-compatible reasoning backends
313
+ // (DeepSeek-R1, z.ai/GLM, vLLM, Ollama, …). The field name varies by
314
+ // vendor — handle both. Official OpenAI Chat Completions doesn't stream
315
+ // it (Responses-API only), so it's simply absent there.
316
+ reasoning_content?: string | null;
317
+ reasoning?: string | null;
318
+ tool_calls?: Array<{
319
+ index?: number;
320
+ id?: string;
321
+ function?: { name?: string; arguments?: string };
322
+ }>;
323
+ };
324
+ finish_reason?: string | null;
325
+ }>;
326
+ usage?: {
327
+ prompt_tokens?: number;
328
+ completion_tokens?: number;
329
+ prompt_tokens_details?: { cached_tokens?: number };
330
+ };
331
+ }
332
+
333
+ function mapStopReason(s: string): StopReason {
334
+ if (s === 'tool_calls') return 'tool_use';
335
+ if (s === 'length') return 'max_tokens';
336
+ if (s === 'stop') return 'end_turn';
337
+ if (s === 'content_filter') return 'error';
338
+ return 'end_turn';
339
+ }