@x-otto/cli 0.0.1-alpha.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 (100) hide show
  1. package/README.md +120 -0
  2. package/bin/otto.js +61 -0
  3. package/dist/auth-command-CXAB3_hs.js +11 -0
  4. package/dist/auth-command-CXAB3_hs.js.map +1 -0
  5. package/dist/build-id-BX4QHzM0.js +3 -0
  6. package/dist/build-id-BX4QHzM0.js.map +1 -0
  7. package/dist/bundled-extensions/plugin-otto-wire-protocols/otto-plugin.json +8 -0
  8. package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/meta.json +1 -0
  9. package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/plugin.cjs +60023 -0
  10. package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin.ts +64 -0
  11. package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-messages.ts +916 -0
  12. package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-usage.ts +81 -0
  13. package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-completions.ts +158 -0
  14. package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-responses.ts +575 -0
  15. package/dist/bundled-extensions/plugin-skill-inductor/otto-plugin.json +8 -0
  16. package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/meta.json +1 -0
  17. package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/plugin.cjs +52781 -0
  18. package/dist/bundled-extensions/plugin-skill-inductor/plugin.ts +77 -0
  19. package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-detector.ts +210 -0
  20. package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-digest.ts +146 -0
  21. package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-store.ts +87 -0
  22. package/dist/bundled-extensions/plugin-skill-inductor/src/episode-store.ts +122 -0
  23. package/dist/bundled-extensions/plugin-skill-inductor/src/episode.ts +125 -0
  24. package/dist/bundled-extensions/plugin-skill-inductor/src/extraction-prompt.ts +97 -0
  25. package/dist/bundled-extensions/plugin-skill-inductor/src/inductor-service.ts +183 -0
  26. package/dist/bundled-extensions/plugin-skill-inductor/src/similarity.ts +58 -0
  27. package/dist/command-dispatcher-BgStVcCF.js +13 -0
  28. package/dist/command-dispatcher-BgStVcCF.js.map +1 -0
  29. package/dist/config-migration-DatflIxs.js +21 -0
  30. package/dist/config-migration-DatflIxs.js.map +1 -0
  31. package/dist/daemon-COESl-zU.js +21 -0
  32. package/dist/daemon-COESl-zU.js.map +1 -0
  33. package/dist/daemon-base-YOWgYYN7.js +3 -0
  34. package/dist/daemon-base-YOWgYYN7.js.map +1 -0
  35. package/dist/debug-CiosFwme.js +34 -0
  36. package/dist/debug-CiosFwme.js.map +1 -0
  37. package/dist/discover-with-bundled-CznLjkkw.js +3 -0
  38. package/dist/discover-with-bundled-CznLjkkw.js.map +1 -0
  39. package/dist/doctor-C84-hhEr.js +30 -0
  40. package/dist/doctor-C84-hhEr.js.map +1 -0
  41. package/dist/doctor-attach-_zeIJewx.js +42 -0
  42. package/dist/doctor-attach-_zeIJewx.js.map +1 -0
  43. package/dist/doctor-cdp-K_mIFRfA.js +5 -0
  44. package/dist/doctor-cdp-K_mIFRfA.js.map +1 -0
  45. package/dist/doctor-heap-Rr2nNvcw.js +11 -0
  46. package/dist/doctor-heap-Rr2nNvcw.js.map +1 -0
  47. package/dist/doctor-ps-OL3FGQYu.js +8 -0
  48. package/dist/doctor-ps-OL3FGQYu.js.map +1 -0
  49. package/dist/extension-Bhzoel6s.js +169 -0
  50. package/dist/extension-Bhzoel6s.js.map +1 -0
  51. package/dist/extension-plugin-CnF6O_zK.js +7 -0
  52. package/dist/extension-plugin-CnF6O_zK.js.map +1 -0
  53. package/dist/extension-plugin-DWcakwRx.js +2 -0
  54. package/dist/extension-v-liRnUX.js +2 -0
  55. package/dist/feedback-BES_6ebi.js +21 -0
  56. package/dist/feedback-BES_6ebi.js.map +1 -0
  57. package/dist/feedback-BRbLUn9u.js +2 -0
  58. package/dist/feishu-setup-D6vnuNLw.js +6 -0
  59. package/dist/feishu-setup-D6vnuNLw.js.map +1 -0
  60. package/dist/index.d.ts +6 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +20 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/install-DlVJRHRT.js +11 -0
  65. package/dist/install-DlVJRHRT.js.map +1 -0
  66. package/dist/install-WCcQ9k9M.js +2 -0
  67. package/dist/mcp-DbTYT7hB.js +12 -0
  68. package/dist/mcp-DbTYT7hB.js.map +1 -0
  69. package/dist/migrate-boDXhEyV.js +14 -0
  70. package/dist/migrate-boDXhEyV.js.map +1 -0
  71. package/dist/model-auth-C27iG6Jq.js +3 -0
  72. package/dist/model-auth-C27iG6Jq.js.map +1 -0
  73. package/dist/model-auth-C4bsWvvn.js +2 -0
  74. package/dist/observe-CVg-MRM2.js +32 -0
  75. package/dist/observe-CVg-MRM2.js.map +1 -0
  76. package/dist/parse-cli-tFDOeVU1.js +3 -0
  77. package/dist/parse-cli-tFDOeVU1.js.map +1 -0
  78. package/dist/persistenced-CCwHu1Ww.js +36 -0
  79. package/dist/persistenced-CCwHu1Ww.js.map +1 -0
  80. package/dist/proxy-BvdBsB1O.js +2 -0
  81. package/dist/proxy-evg7ZvoD.js +3 -0
  82. package/dist/proxy-evg7ZvoD.js.map +1 -0
  83. package/dist/ps-DKrluEuy.js +9 -0
  84. package/dist/ps-DKrluEuy.js.map +1 -0
  85. package/dist/registry-B3nfN62q.js +2 -0
  86. package/dist/registry-D0NONjLv.js +6 -0
  87. package/dist/registry-D0NONjLv.js.map +1 -0
  88. package/dist/remote-persistence-server-B_hONoVR.js +35 -0
  89. package/dist/remote-persistence-server-B_hONoVR.js.map +1 -0
  90. package/dist/run-attach-Ci5jYyCa.js +3 -0
  91. package/dist/run-attach-Ci5jYyCa.js.map +1 -0
  92. package/dist/serve-pVR6UIT9.js +21 -0
  93. package/dist/serve-pVR6UIT9.js.map +1 -0
  94. package/dist/service-client-BLNeugM9.js +3 -0
  95. package/dist/service-client-BLNeugM9.js.map +1 -0
  96. package/dist/time-travel-ZozuPPI-.js +21 -0
  97. package/dist/time-travel-ZozuPPI-.js.map +1 -0
  98. package/dist/version-C0qM-qJM.js +3 -0
  99. package/dist/version-C0qM-qJM.js.map +1 -0
  100. package/package.json +61 -0
@@ -0,0 +1,575 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { stream, ProviderError } from '@x-otto/shared'
4
+ import { normalizeImageData } from '@x-otto/interchange'
5
+ import {
6
+ buildRequestHeaders,
7
+ extractStandardRateLimitHeaders,
8
+ sanitizeToolRoundtrips,
9
+ degradedSystemNotificationText,
10
+ sanitizeSurrogates,
11
+ toJsonSchema,
12
+ assembleAssistantMessage,
13
+ blocksContent,
14
+ initStreamUsage,
15
+ toolCallFromParsed,
16
+ recoverLiteralInvokeToolCalls,
17
+ registeredToolNames,
18
+ clampThinkingLevel,
19
+ } from '@x-otto/provider'
20
+ import type {
21
+ AssistantMessage,
22
+ Model,
23
+ ResolvedAuth,
24
+ StopReason,
25
+ StreamContext,
26
+ StreamEvent,
27
+ StreamOptions,
28
+ TextContent,
29
+ ProviderStream,
30
+ StreamBlock,
31
+ StreamUsage,
32
+ TokenDerivedHeader,
33
+ WireProtocolLogger,
34
+ WireRequestCustomization,
35
+ WireRequestCustomizeInput,
36
+ } from '@x-otto/provider'
37
+
38
+ /** RFC-185 D3:日志走宿主注入端口,缺省 no-op——插件源码禁 createLogger(防第二单例直写 stderr)。 */
39
+ const noopLogger: WireProtocolLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }
40
+
41
+ export type OpenAIResponsesCredentialResolver = () => Promise<string | undefined>
42
+ export type OpenAIResponsesBaseUrlResolver = () => Promise<string | undefined>
43
+
44
+ // RFC-122 D3:TokenDerivedHeader 定义单一真源在 @x-otto/provider(协议中立层,见该包
45
+ // types.ts 头注释)——此前本文件与 openai-completions.ts 各自重复定义一份相同形状,
46
+ // 终局 review 收敛为直接 import,避免两份定义静默漂移。
47
+
48
+ export function toReasoningEffort(level: string): 'low' | 'medium' | 'high' {
49
+ // RFC-235:OpenAI Responses reasoning.effort 官方支持 minimal/low/medium/high(**无 max**)。
50
+ // otto 词表去掉了 minimal(对齐 Claude Code),故本翻译表映射到 low/medium/high;xhigh/max
51
+ // 降级为 high(OpenAI 无更高档)。降级已在调用处用 clampThinkingLevel 完成。
52
+ switch (level) {
53
+ case 'low':
54
+ return 'low'
55
+ case 'medium':
56
+ return 'medium'
57
+ case 'high':
58
+ case 'xhigh':
59
+ case 'max':
60
+ return 'high'
61
+ default:
62
+ return 'medium'
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 将内部消息转换为 OpenAI Responses `input` 数组。
68
+ *
69
+ * - user/assistant 文本走 `input_text`/`output_text` content part;
70
+ * - 工具调用 → `function_call`(call_id + arguments 字符串);
71
+ * - 工具结果 → `function_call_output`(call_id + output 字符串)。
72
+ */
73
+ // RFC-144 M2:OpenAI Responses 的 `input` 数组 role 限定 `'developer'|'user'|'assistant'`
74
+ // (无 'system',系统指令走顶层 `instructions` 字段,见 buildResponsesBody),结构上不存在
75
+ // "消息数组中间插入系统级消息"的原生机制,走决策 1 已述的降级路径:`role:'user'` + 显式前缀
76
+ // (`degradedSystemNotificationText`,见 sanitize-tool-roundtrips.ts 单一真源)。
77
+
78
+ export function buildResponsesInput(context: StreamContext): unknown[] {
79
+ const input: unknown[] = []
80
+
81
+ for (const msg of sanitizeToolRoundtrips(context.messages)) {
82
+ if (msg.role === 'user') {
83
+ if (typeof msg.content === 'string') {
84
+ input.push({
85
+ role: 'user',
86
+ content: [{ type: 'input_text', text: sanitizeSurrogates(msg.content) }],
87
+ })
88
+ } else {
89
+ input.push({
90
+ role: 'user',
91
+ content: msg.content.map((part) => {
92
+ if (part.type === 'text') {
93
+ return { type: 'input_text', text: sanitizeSurrogates(part.text) }
94
+ }
95
+ if (part.type === 'image') {
96
+ const { base64, mediaType, valid } = normalizeImageData(part.source, part.mime)
97
+ if (!valid) {
98
+ return { type: 'input_text', text: '[image omitted: corrupted or empty data]' }
99
+ }
100
+ return {
101
+ type: 'input_image',
102
+ image_url: `data:${mediaType || 'image/png'};base64,${base64}`,
103
+ }
104
+ }
105
+ return { type: 'input_text', text: '[unsupported]' }
106
+ }),
107
+ })
108
+ }
109
+ } else if (msg.role === 'assistant') {
110
+ const textParts = msg.content
111
+ .filter((c): c is TextContent => c.type === 'text')
112
+ .filter((c) => c.text.trim().length > 0)
113
+ .map((c) => sanitizeSurrogates(c.text))
114
+ .join('')
115
+
116
+ if (textParts) {
117
+ input.push({
118
+ role: 'assistant',
119
+ content: [{ type: 'output_text', text: textParts }],
120
+ })
121
+ }
122
+
123
+ for (const c of msg.content) {
124
+ if (c.type === 'tool_call') {
125
+ input.push({
126
+ type: 'function_call',
127
+ call_id: c.id,
128
+ name: c.name,
129
+ arguments: JSON.stringify(c.arguments),
130
+ })
131
+ }
132
+ }
133
+ } else if (msg.role === 'tool_result') {
134
+ input.push({
135
+ type: 'function_call_output',
136
+ call_id: msg.toolCallId,
137
+ output: sanitizeSurrogates(
138
+ msg.content.map((c) => (c.type === 'text' ? c.text : '')).join(''),
139
+ ),
140
+ })
141
+ } else if (msg.role === 'system_notification') {
142
+ input.push({
143
+ role: 'user',
144
+ content: [
145
+ { type: 'input_text', text: sanitizeSurrogates(degradedSystemNotificationText(msg.content)) },
146
+ ],
147
+ })
148
+ }
149
+ }
150
+
151
+ return input
152
+ }
153
+
154
+ export function buildResponsesBody(model: Model, context: StreamContext): Record<string, unknown> {
155
+ const modelName = model.name
156
+
157
+ const body: Record<string, unknown> = {
158
+ model: modelName,
159
+ input: buildResponsesInput(context),
160
+ stream: true,
161
+ }
162
+
163
+ // RFC-213:请求体策略完全由 manifest 声明式驱动(Model.responseBodyPolicy),
164
+ // 零厂商字符串判断——此前的 `model.api === 'openai-codex-responses'` 分支自 RFC-122
165
+ // provider 插件化迁移后已物理不可达(Codex 插件的 preserveProviderId:true 令
166
+ // model.api 实际值为裸 id "openai-codex",见 RFC-213 背景的逐层源码追溯),是死代码,
167
+ // 且官方 openai/codex CLI 源码从不发送 max_output_tokens(reasoning 模型系列经
168
+ // Responses API 收到该字段有被 400 拒绝的已知风险)。
169
+ const policy = model.responseBodyPolicy
170
+ if (policy?.store !== undefined) {
171
+ body.store = policy.store
172
+ }
173
+ if (policy?.sendMaxOutputTokens !== false) {
174
+ body.max_output_tokens = context.maxTokens ?? model.maxOutputTokens
175
+ }
176
+
177
+ if (context.systemPrompt) {
178
+ body.instructions = sanitizeSurrogates(context.systemPrompt)
179
+ }
180
+
181
+ if (context.temperature !== undefined && !model.reasoning) {
182
+ body.temperature = context.temperature
183
+ }
184
+ if (model.reasoning && context.thinkingLevel) {
185
+ body.reasoning = { effort: toReasoningEffort(clampThinkingLevel(context.thinkingLevel, model.thinkingLevels)) }
186
+ }
187
+
188
+ if (context.tools && context.tools.length > 0) {
189
+ body.tools = context.tools.map((tool) => ({
190
+ type: 'function',
191
+ name: tool.name,
192
+ description: tool.description,
193
+ parameters: toJsonSchema(tool.parameters),
194
+ }))
195
+ }
196
+
197
+ return body
198
+ }
199
+
200
+ interface ResponsesStreamState extends StreamUsage {
201
+ model: Model
202
+ blocks: Map<number, StreamBlock>
203
+ stopReason: StopReason
204
+ }
205
+
206
+ function ensureBlock(
207
+ blocks: Map<number, StreamBlock>,
208
+ index: number,
209
+ kind: 'text' | 'thinking',
210
+ ): StreamBlock {
211
+ let block = blocks.get(index)
212
+ if (!block) {
213
+ block = kind === 'text' ? { kind: 'text', text: '' } : { kind: 'thinking', text: '' }
214
+ blocks.set(index, block)
215
+ }
216
+ return block
217
+ }
218
+
219
+ function initStreamState(model: Model): ResponsesStreamState {
220
+ return {
221
+ ...initStreamUsage(),
222
+ model,
223
+ blocks: new Map(),
224
+ stopReason: 'end_turn',
225
+ }
226
+ }
227
+
228
+ function buildAssistantMessage(state: ResponsesStreamState, final = false): AssistantMessage {
229
+ return assembleAssistantMessage(
230
+ { api: state.model.api, provider: state.model.provider, model: state.model.id },
231
+ blocksContent(state.blocks, final),
232
+ state,
233
+ state.stopReason,
234
+ )
235
+ }
236
+
237
+ /**
238
+ * 单次 SSE 事件分发。解析 type + data 并更新 state,返回本事件应输出的 StreamEvent 数组。
239
+ * 不存在 type 匹配时返回空数组。
240
+ *
241
+ * `response.failed` / `error` 事件设置 fatal=true,调用方 yield 完 events 后须立即退出
242
+ * converse() 生成器(等效于原 switch 中的 `return`)。
243
+ */
244
+ function dispatchResponsesSSEEvent(
245
+ type: string | undefined,
246
+ data: Record<string, unknown>,
247
+ state: ResponsesStreamState,
248
+ ): { events: StreamEvent[]; fatal: boolean } {
249
+ switch (type) {
250
+ case 'response.output_item.added': {
251
+ const index = (data.output_index as number) ?? 0
252
+ const item = data.item as Record<string, unknown> | undefined
253
+ if (item?.type === 'function_call') {
254
+ state.blocks.set(index, {
255
+ kind: 'tool_call',
256
+ id: (item.call_id as string) ?? (item.id as string) ?? `call_${index}`,
257
+ name: (item.name as string) ?? '',
258
+ argumentsJson: '',
259
+ })
260
+ return { events: [{ type: 'tool_call_start', index, partial: buildAssistantMessage(state) }], fatal: false }
261
+ } else if (item?.type === 'reasoning') {
262
+ ensureBlock(state.blocks, index, 'thinking')
263
+ return { events: [{ type: 'thinking_start', index, partial: buildAssistantMessage(state) }], fatal: false }
264
+ } else if (item?.type === 'message') {
265
+ ensureBlock(state.blocks, index, 'text')
266
+ return { events: [{ type: 'text_start', index, partial: buildAssistantMessage(state) }], fatal: false }
267
+ }
268
+ return { events: [], fatal: false }
269
+ }
270
+
271
+ case 'response.output_text.delta': {
272
+ const index = (data.output_index as number) ?? 0
273
+ const delta = (data.delta as string) ?? ''
274
+ const block = ensureBlock(state.blocks, index, 'text')
275
+ if (block.kind === 'text') {
276
+ block.text += delta
277
+ return { events: [{ type: 'text_delta', index, delta, partial: buildAssistantMessage(state) }], fatal: false }
278
+ }
279
+ return { events: [], fatal: false }
280
+ }
281
+
282
+ case 'response.reasoning_summary_text.delta':
283
+ case 'response.reasoning_text.delta': {
284
+ const index = (data.output_index as number) ?? 0
285
+ const delta = (data.delta as string) ?? ''
286
+ const block = ensureBlock(state.blocks, index, 'thinking')
287
+ if (block.kind === 'thinking') {
288
+ block.text += delta
289
+ return { events: [{ type: 'thinking_delta', index, delta, partial: buildAssistantMessage(state) }], fatal: false }
290
+ }
291
+ return { events: [], fatal: false }
292
+ }
293
+
294
+ case 'response.function_call_arguments.delta': {
295
+ const index = (data.output_index as number) ?? 0
296
+ const delta = (data.delta as string) ?? ''
297
+ const block = state.blocks.get(index)
298
+ if (block?.kind === 'tool_call') {
299
+ block.argumentsJson += delta
300
+ return { events: [{ type: 'tool_call_delta', index, delta, partial: buildAssistantMessage(state) }], fatal: false }
301
+ }
302
+ return { events: [], fatal: false }
303
+ }
304
+
305
+ case 'response.completed':
306
+ case 'response.incomplete': {
307
+ const response = data.response as Record<string, unknown> | undefined
308
+ const usage = response?.usage as Record<string, unknown> | undefined
309
+ if (usage) {
310
+ const inputTokens = (usage.input_tokens as number) ?? 0
311
+ const outputTokens = (usage.output_tokens as number) ?? 0
312
+ const cached =
313
+ (usage.input_tokens_details as Record<string, number> | undefined)?.cached_tokens ??
314
+ 0
315
+ state.inputTokens = inputTokens - cached
316
+ state.outputTokens = outputTokens
317
+ state.cacheReadTokens = cached
318
+ }
319
+ const hasTool = [...state.blocks.values()].some((b) => b.kind === 'tool_call')
320
+ if (type === 'response.incomplete') {
321
+ state.stopReason = 'max_tokens'
322
+ } else {
323
+ state.stopReason = hasTool ? 'tool_use' : 'end_turn'
324
+ }
325
+ return { events: [], fatal: false }
326
+ }
327
+
328
+ case 'response.failed':
329
+ case 'error': {
330
+ const response = data.response as Record<string, unknown> | undefined
331
+ const errObj = (response?.error ?? data.error) as Record<string, unknown> | undefined
332
+ const message = (errObj?.message as string) ?? 'OpenAI Responses stream error'
333
+ // OpenAI 错误体形如 { message, type, code, param }(如 type:'invalid_request_error',
334
+ // code:'invalid_api_key')——code 比 type 更具体,优先取 code,回退 type(同 Anthropic
335
+ // errorType 语义:供应商机器可读的失效根因分类,非 otto 内部 code)。
336
+ const errorType =
337
+ (errObj?.code as string | undefined) ?? (errObj?.type as string | undefined)
338
+ return {
339
+ events: [{
340
+ type: 'error',
341
+ error: new ProviderError(`OpenAI Responses API error: ${message}`, {
342
+ code: 'PROVIDER_API_ERROR',
343
+ errorType,
344
+ }),
345
+ }],
346
+ fatal: true,
347
+ }
348
+ }
349
+
350
+ default:
351
+ return { events: [], fatal: false }
352
+ }
353
+ }
354
+
355
+ class OpenAIResponsesStream implements ProviderStream {
356
+ readonly id = 'openai-responses'
357
+ readonly displayName = 'OpenAI'
358
+
359
+ constructor(
360
+ private readonly resolveCredential?: OpenAIResponsesCredentialResolver,
361
+ private readonly baseUrlResolver?: OpenAIResponsesBaseUrlResolver,
362
+ private readonly tokenDerivedHeaders?: TokenDerivedHeader[],
363
+ /**
364
+ * 终局 review 2026-07-10 P1-7:静态额外 headers(如 vendor 自定义头),对齐
365
+ * `OpenAICompletionsProviderOptions.headers` 已有模式——此前本类没有这个字段,
366
+ * `openai-codex-responses` 的 `originator` 头只能靠 wire 内部硬编码分支实现,
367
+ * 与 `plugin-openai-codex/otto-plugin.json` 里 `contributes.providers[0].headers`
368
+ * 的声明形成"双份真相"(manifest 声明了但从未被消费,硬编码才是唯一生效路径)。
369
+ * 补齐此字段后硬编码分支已冗余,随本次一并删除(见下方 buildHeaders)。
370
+ */
371
+ private readonly staticHeaders?: Record<string, string>,
372
+ private readonly logger: WireProtocolLogger = noopLogger,
373
+ /** 见 `OpenAIResponsesProviderOptions.customizeRequest`。缺省 = 零定制。 */
374
+ private readonly customizeRequest?: (input: WireRequestCustomizeInput) => WireRequestCustomization,
375
+ private readonly allowAuthHeaderOverride?: boolean,
376
+ ) {}
377
+
378
+ /**
379
+ * 进程内稳定的会话 id——供 `customizeRequest` 定制器使用(对齐 anthropic-messages
380
+ * 的同名字段,RFC-270 D2 终局 review F2:此前本协议无 sessionId,钩子入参无法构造)。
381
+ */
382
+ private readonly requestSessionId = randomUUID()
383
+
384
+ /** 统一鉴权解析(RFC-140 D5,`ProviderStream.resolveAuth`)——OpenAI Responses 恒 api_key 模式。 */
385
+ async resolveAuth(): Promise<ResolvedAuth> {
386
+ if (this.resolveCredential) {
387
+ const key = await this.resolveCredential()
388
+ if (key) {
389
+ return { token: key, mode: 'api_key' }
390
+ }
391
+ }
392
+
393
+ const envKey = process.env['OPENAI_API_KEY']
394
+ if (envKey) {
395
+ return { token: envKey, mode: 'api_key' }
396
+ }
397
+
398
+ throw new ProviderError('Missing OpenAI credential. Set OPENAI_API_KEY or login via OAuth.', {
399
+ code: 'PROVIDER_AUTH_MISSING',
400
+ })
401
+ }
402
+
403
+ private async resolveBaseUrl(model: Model): Promise<string> {
404
+ const envUrl = process.env['OPENAI_BASE_URL']
405
+ if (envUrl) {
406
+ return envUrl
407
+ }
408
+ if (this.baseUrlResolver) {
409
+ const url = await this.baseUrlResolver()
410
+ if (url) {
411
+ return url
412
+ }
413
+ }
414
+ return model.baseUrl
415
+ }
416
+
417
+ /**
418
+ * RFC-270 D1:头组装收敛到 @x-otto/provider 的统一管线(此前本方法自行合并,且注释声称
419
+ * 与 openai-completions.ts 顺序一致——实为相反,是无人察觉的漂移)。鉴权头 write-last
420
+ * 不可被 ②③④ 档覆盖,除非 provider 显式声明 allowAuthHeaderOverride。
421
+ */
422
+ private buildHeaders(auth: ResolvedAuth, providerLabel: string): Record<string, string> {
423
+ const customization = this.customizeRequest?.({
424
+ auth: { mode: auth.mode, beta: auth.beta },
425
+ sessionId: this.requestSessionId,
426
+ })
427
+
428
+ return buildRequestHeaders({
429
+ staticHeaders: this.staticHeaders,
430
+ tokenDerived: this.tokenDerivedHeaders?.length
431
+ ? { headers: this.tokenDerivedHeaders, token: auth.token }
432
+ : undefined,
433
+ customized: customization?.defaultHeaders,
434
+ auth: { authorization: `Bearer ${auth.token}` },
435
+ allowAuthHeaderOverride: this.allowAuthHeaderOverride,
436
+ logger: this.logger,
437
+ diagnosticLabel: `openai-responses:${providerLabel}`,
438
+ })
439
+ }
440
+
441
+ async *converse(
442
+ model: Model,
443
+ context: StreamContext,
444
+ options: StreamOptions,
445
+ signal: AbortSignal,
446
+ ): AsyncIterable<StreamEvent> {
447
+ const auth = await this.resolveAuth()
448
+ const baseUrl = await this.resolveBaseUrl(model)
449
+ const url = `${baseUrl}/responses`
450
+ const body = buildResponsesBody(model, context)
451
+
452
+ this.logger.debug({ model: model.id }, 'Starting OpenAI Responses stream')
453
+
454
+ const state = initStreamState(model)
455
+ let started = false
456
+
457
+ try {
458
+ // ChatGPT Codex 后端响应 headers 暂存闭包——onResponse 在 fetch 成功后立即同步触发
459
+ //(http.ts stream() 在 response.ok 后、读 body 前调用),保存下来的 headers 在 done
460
+ // 事件 yield 时读取(此时 fetch 已结束,但 headers 对象的引用仍然存活)。
461
+ let responseHeaders: Headers | undefined
462
+ const sse = stream({
463
+ url,
464
+ body,
465
+ headers: this.buildHeaders(auth, model.provider),
466
+ signal,
467
+ timeout: options.timeout ?? 300000,
468
+ onResponse: (h) => {
469
+ responseHeaders = h
470
+ },
471
+ })
472
+
473
+ for await (const ev of sse) {
474
+ if (ev.data === '[DONE]') {
475
+ break
476
+ }
477
+
478
+ let data: Record<string, unknown>
479
+ try {
480
+ data = JSON.parse(ev.data) as Record<string, unknown>
481
+ } catch {
482
+ this.logger.warn('Failed to parse Responses SSE data')
483
+ continue
484
+ }
485
+
486
+ if (!started) {
487
+ started = true
488
+ yield { type: 'start', partial: buildAssistantMessage(state) }
489
+ }
490
+
491
+ const type = ev.event ?? (data.type as string | undefined)
492
+ const result = dispatchResponsesSSEEvent(type, data, state)
493
+ yield* result.events
494
+ if (result.fatal) return
495
+ }
496
+
497
+ const orderedTools = [...state.blocks.entries()].sort(([a], [b]) => a - b)
498
+ for (const [index, block] of orderedTools) {
499
+ if (block.kind === 'tool_call') {
500
+ yield {
501
+ type: 'tool_call_end',
502
+ index,
503
+ toolCall: toolCallFromParsed(block.id, block.name, block.argumentsJson),
504
+ partial: buildAssistantMessage(state),
505
+ }
506
+ } else if (block.kind === 'text') {
507
+ // 补 text_end(缺 end 会致完成通知空 + 投影缺口)。
508
+ yield { type: 'text_end', index, content: block.text, partial: buildAssistantMessage(state) }
509
+ } else if (block.kind === 'thinking') {
510
+ yield {
511
+ type: 'thinking_end',
512
+ index,
513
+ content: block.text,
514
+ partial: buildAssistantMessage(state),
515
+ }
516
+ }
517
+ }
518
+
519
+ yield {
520
+ type: 'done',
521
+ reason: state.stopReason,
522
+ // RFC-232(终局修正):跨 wire 协议救回违约响应,见 @x-otto/provider stream-state.ts。
523
+ message: recoverLiteralInvokeToolCalls(buildAssistantMessage(state, true), registeredToolNames(context.tools)),
524
+ providerUsage: extractStandardRateLimitHeaders(responseHeaders),
525
+ }
526
+ } catch (error) {
527
+ if (error instanceof Error) {
528
+ yield { type: 'error', error }
529
+ } else {
530
+ yield {
531
+ type: 'error',
532
+ error: new ProviderError('Unknown OpenAI Responses stream error', {
533
+ code: 'PROVIDER_STREAM_ERROR',
534
+ }),
535
+ }
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ export interface OpenAIResponsesProviderOptions {
542
+ resolveKey?: OpenAIResponsesCredentialResolver
543
+ resolveBaseUrl?: OpenAIResponsesBaseUrlResolver
544
+ /** RFC-122 D3:插件声明式 token 派生 header(provider-loader 装载时注入) */
545
+ tokenDerivedHeaders?: TokenDerivedHeader[]
546
+ /**
547
+ * 终局 review 2026-07-10 P1-7:静态额外 headers(如 codex 插件声明的 `originator`),
548
+ * 对齐 `OpenAICompletionsProviderOptions.headers` 已有模式。
549
+ */
550
+ headers?: Record<string, string>
551
+ /**
552
+ * RFC-270 D2:程序化请求定制点(拦截器)。此前只有 anthropic-messages 消费该钩子,
553
+ * 本协议补齐——返回的 `defaultHeaders` 并入头管线第 ④ 档。`transformSystemBlocks`
554
+ * 不适用于本协议,按 `WireRequestCustomization` 契约忽略。
555
+ */
556
+ customizeRequest?: (input: WireRequestCustomizeInput) => WireRequestCustomization
557
+ /** RFC-270 D3:manifest 显式声明后,允许自定义头覆盖鉴权头(企业网关换鉴权)。 */
558
+ allowAuthHeaderOverride?: boolean
559
+ /** RFC-185 D3:宿主注入的日志端口,缺省 no-op。 */
560
+ logger?: WireProtocolLogger
561
+ }
562
+
563
+ export function createOpenAIResponsesProvider(
564
+ options: OpenAIResponsesProviderOptions = {},
565
+ ): ProviderStream {
566
+ return new OpenAIResponsesStream(
567
+ options.resolveKey,
568
+ options.resolveBaseUrl,
569
+ options.tokenDerivedHeaders,
570
+ options.headers,
571
+ options.logger,
572
+ options.customizeRequest,
573
+ options.allowAuthHeaderOverride,
574
+ )
575
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "plugin-skill-inductor",
3
+ "name": "Skill Inductor",
4
+ "version": "0.1.0",
5
+ "description": "Observes completed task trajectories and distills them into structured episodes — the data source for auto-induced skills (RFC-318 skill loop).",
6
+ "capabilities": ["service", "storage", "session.read", "llm.complete", "user.notify"],
7
+ "scripts": { "postinstall": "otto extension build" }
8
+ }
@@ -0,0 +1 @@
1
+ {"apiVersion":1,"compilerVersion":"0.1.0-alpha.1","sourceHash":"5b28454cfded236868d7031600579b7aa59f72785a003a28034b3cb81c5b70ae"}