@worca/app 1.4.0 → 1.5.0-rc.3

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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -0,0 +1,144 @@
1
+ // src/core/bridge/translate/common.mjs
2
+ // Helpers the two translated wire protocols share — openai-chat
3
+ // (request.mjs) and openai-responses (responses-request.mjs /
4
+ // responses-stream.mjs): text, image and tool_result flattening, the
5
+ // tool-search references, the effort a request asks for and how it maps to
6
+ // the model's own levels, and the reasoning marker the Responses translator
7
+ // round-trips through the thinking blocks the CLI echoes back
8
+ // (2026-09-23-bridge-openai-responses-design.md §7–§8). Pure.
9
+
10
+ import { Buffer } from 'node:buffer';
11
+ import { REASONING_EFFORT_LEVELS } from '../../model-env.mjs';
12
+
13
+ /** Anthropic server-side tool types — no OpenAI equivalent (§5.3). */
14
+ export const SERVER_TOOL_RE = /^(web_search|web_fetch|computer|text_editor|bash|code_execution|memory)(_\d{8})?$/;
15
+
16
+ /** Text of a block list (text blocks only), joined with blank lines. */
17
+ export function textOf(content) {
18
+ if (typeof content === 'string') return content;
19
+ if (!Array.isArray(content)) return '';
20
+ return content
21
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
22
+ .map((b) => b.text)
23
+ .join('\n\n');
24
+ }
25
+
26
+ /** An Anthropic image block's URL: a data URI for a base64 source, the URL for a url source, else null. */
27
+ export function imageUrl(block) {
28
+ const src = (block && block.source) || {};
29
+ if (src.type === 'base64' && src.media_type && src.data) return `data:${src.media_type};base64,${src.data}`;
30
+ if (src.type === 'url' && typeof src.url === 'string') return src.url;
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Flatten a tool_result's content to text; image URLs come back separately
36
+ * (neither OpenAI protocol takes an image inside a tool output — the
37
+ * translators relocate them to a following user message, §5.2).
38
+ * @returns {{text:string, imageUrls:string[]}}
39
+ */
40
+ export function flattenToolResult(content, caps, warn) {
41
+ if (typeof content === 'string') return { text: content, imageUrls: [] };
42
+ if (!Array.isArray(content)) return { text: content == null ? '' : String(content), imageUrls: [] };
43
+ const texts = [];
44
+ const imageUrls = [];
45
+ for (const b of content) {
46
+ if (!b || typeof b !== 'object') continue;
47
+ if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
48
+ // ToolSearch's result: the named tool is now in `tools` on this request.
49
+ else if (b.type === 'tool_reference') texts.push(`Tool loaded: ${b.tool_name || b.name || 'unknown'}`);
50
+ else if (b.type === 'image') {
51
+ if (caps.vision === false) { warn('image'); texts.push('[image omitted: model has no vision]'); }
52
+ else { const u = imageUrl(b); if (u) imageUrls.push(u); else warn('image'); }
53
+ } else warn(b.type);
54
+ }
55
+ return { text: texts.join('\n\n'), imageUrls };
56
+ }
57
+
58
+ /**
59
+ * The deferred tools a ToolSearch result has loaded: the names in
60
+ * tool_reference blocks. The Anthropic API expands them against the request's
61
+ * `defer_loading` tools server-side; the OpenAI protocols have no such step, so
62
+ * the translators send a deferred tool only once it is referenced.
63
+ */
64
+ export function referencedToolNames(messages) {
65
+ const referenced = new Set();
66
+ for (const m of Array.isArray(messages) ? messages : []) {
67
+ if (!m || !Array.isArray(m.content)) continue;
68
+ for (const b of m.content) {
69
+ if (!b || b.type !== 'tool_result' || !Array.isArray(b.content)) continue;
70
+ for (const r of b.content) if (r && r.type === 'tool_reference' && r.tool_name) referenced.add(String(r.tool_name));
71
+ }
72
+ }
73
+ return referenced;
74
+ }
75
+
76
+ /** Thinking budget bands -> effort (§5.1). */
77
+ export function budgetToReasoningEffort(budget) {
78
+ const n = Number(budget);
79
+ if (!Number.isFinite(n) || n <= 0) return 'medium';
80
+ if (n < 4000) return 'low';
81
+ if (n < 16000) return 'medium';
82
+ return 'high';
83
+ }
84
+
85
+ const REQUESTABLE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
86
+
87
+ /**
88
+ * The effort a Messages request asks for: `output_config.effort` (the CLI's
89
+ * --effort) wins over an enabled thinking budget's band; `thinking:
90
+ * {type:'adaptive'}` alone asks for nothing. null when neither is present.
91
+ */
92
+ export function requestedEffort(body) {
93
+ if (!body || typeof body !== 'object') return null;
94
+ let effort = null;
95
+ if (body.thinking && typeof body.thinking === 'object' && body.thinking.type === 'enabled') {
96
+ effort = budgetToReasoningEffort(body.thinking.budget_tokens);
97
+ }
98
+ const oc = body.output_config;
99
+ if (oc && typeof oc === 'object' && typeof oc.effort === 'string' && REQUESTABLE_EFFORTS.has(oc.effort)) effort = oc.effort;
100
+ return effort;
101
+ }
102
+
103
+ /** Requested effort -> upstream effort when the model lists no levels (the chat bridge's original table). */
104
+ const DEFAULT_EFFORT_MAP = { low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' };
105
+
106
+ /**
107
+ * Map a requested effort onto the upstream's reasoning effort (spec §7.4).
108
+ * With `capabilities.reasoningEfforts`: the highest listed level at or below
109
+ * the request, else the lowest listed level above it (`none` is never
110
+ * chosen). Without a list: DEFAULT_EFFORT_MAP.
111
+ * @returns {string|null}
112
+ */
113
+ export function mapEffort(requested, capabilities = {}) {
114
+ if (!requested) return null;
115
+ const listed = capabilities && Array.isArray(capabilities.reasoningEfforts) ? capabilities.reasoningEfforts : [];
116
+ const levels = REASONING_EFFORT_LEVELS.filter((e) => e !== 'none' && listed.includes(e));
117
+ if (!levels.length) return DEFAULT_EFFORT_MAP[requested] || 'medium';
118
+ const want = REASONING_EFFORT_LEVELS.indexOf(requested);
119
+ const atOrBelow = levels.filter((e) => REASONING_EFFORT_LEVELS.indexOf(e) <= want);
120
+ return atOrBelow.length ? atOrBelow[atOrBelow.length - 1] : levels[0];
121
+ }
122
+
123
+ const MARKER_PREFIX = 'worca.rsn.v1.';
124
+
125
+ /**
126
+ * The reasoning marker: what the Responses translator puts in a thinking
127
+ * block's `signature` (or a redacted_thinking block's `data`) so the CLI
128
+ * carries the model's encrypted reasoning to the next request (spec §8).
129
+ * `<prefix><base64url(upstream model)>.<encrypted_content>`.
130
+ */
131
+ export function encodeReasoningMarker(upstreamModel, encrypted) {
132
+ return `${MARKER_PREFIX}${Buffer.from(String(upstreamModel || ''), 'utf8').toString('base64url')}.${encrypted}`;
133
+ }
134
+
135
+ /** Parse a reasoning marker; null for anything else (a real Claude signature, a truncated value). */
136
+ export function decodeReasoningMarker(s) {
137
+ if (typeof s !== 'string' || !s.startsWith(MARKER_PREFIX)) return null;
138
+ const rest = s.slice(MARKER_PREFIX.length);
139
+ const dot = rest.indexOf('.');
140
+ if (dot <= 0 || dot === rest.length - 1) return null;
141
+ const model = Buffer.from(rest.slice(0, dot), 'base64url').toString('utf8');
142
+ if (!model) return null;
143
+ return { model, encrypted: rest.slice(dot + 1) };
144
+ }
@@ -8,42 +8,19 @@
8
8
  //
9
9
  // Scope is what Claude Code actually sends. Reference implementations for the
10
10
  // edge cases: ericc-ch/copilot-api, voidsteed/copilot-proxy-api (both MIT).
11
+ // Helpers shared with the Responses translator live in common.mjs.
11
12
 
12
- /** Anthropic server-side tool types — no chat/completions equivalent (§5.3). */
13
- export const SERVER_TOOL_RE = /^(web_search|web_fetch|computer|text_editor|bash|code_execution|memory)(_\d{8})?$/;
13
+ import {
14
+ SERVER_TOOL_RE, budgetToReasoningEffort, textOf, imageUrl, flattenToolResult,
15
+ referencedToolNames, requestedEffort, mapEffort,
16
+ } from './common.mjs';
14
17
 
15
- /** Efforts (CLI `--effort` -> `output_config.effort`) -> OpenAI reasoning_effort. */
16
- const EFFORT_TO_REASONING = { low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' };
18
+ export { SERVER_TOOL_RE, budgetToReasoningEffort };
17
19
 
18
- /** Thinking budget bands -> reasoning_effort (§5.1). */
19
- export function budgetToReasoningEffort(budget) {
20
- const n = Number(budget);
21
- if (!Number.isFinite(n) || n <= 0) return 'medium';
22
- if (n < 4000) return 'low';
23
- if (n < 16000) return 'medium';
24
- return 'high';
25
- }
26
-
27
- /** Text of a block list (text blocks only), joined with blank lines. */
28
- function textOf(content) {
29
- if (typeof content === 'string') return content;
30
- if (!Array.isArray(content)) return '';
31
- return content
32
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
33
- .map((b) => b.text)
34
- .join('\n\n');
35
- }
36
-
37
- /** An Anthropic image block -> chat/completions image_url part (data URI). */
20
+ /** An Anthropic image block -> chat/completions image_url part (data URI), or null. */
38
21
  function imagePart(block) {
39
- const src = block.source || {};
40
- if (src.type === 'base64' && src.media_type && src.data) {
41
- return { type: 'image_url', image_url: { url: `data:${src.media_type};base64,${src.data}` } };
42
- }
43
- if (src.type === 'url' && typeof src.url === 'string') {
44
- return { type: 'image_url', image_url: { url: src.url } };
45
- }
46
- return null;
22
+ const url = imageUrl(block);
23
+ return url ? { type: 'image_url', image_url: { url } } : null;
47
24
  }
48
25
 
49
26
  /**
@@ -81,29 +58,10 @@ function compact(parts) {
81
58
  return parts;
82
59
  }
83
60
 
84
- /** Flatten a tool_result's content to text; images come back separately (§5.2). */
85
- function toolResultContent(content, caps, warn) {
86
- if (typeof content === 'string') return { text: content, images: [] };
87
- if (!Array.isArray(content)) return { text: content == null ? '' : String(content), images: [] };
88
- const texts = [];
89
- const images = [];
90
- for (const b of content) {
91
- if (!b || typeof b !== 'object') continue;
92
- if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
93
- // ToolSearch's result: the named tool is now in `tools` on this request.
94
- else if (b.type === 'tool_reference') texts.push(`Tool loaded: ${b.tool_name || b.name || 'unknown'}`);
95
- else if (b.type === 'image') {
96
- if (caps.vision === false) { warn('image'); texts.push('[image omitted: model has no vision]'); }
97
- else { const p = imagePart(b); if (p) images.push(p); else warn('image'); }
98
- } else warn(b.type);
99
- }
100
- return { text: texts.join('\n\n'), images };
101
- }
102
-
103
61
  /**
104
62
  * Translate one Messages API request body.
105
63
  * @param {object} body the Anthropic request as the CLI sent it
106
- * @param {{upstreamModel:string, capabilities?:{toolCalls?:boolean, vision?:boolean, reasoning?:boolean, maxOutputTokens?:number}}} opts
64
+ * @param {{upstreamModel:string, capabilities?:{toolCalls?:boolean, vision?:boolean, reasoning?:boolean, maxOutputTokens?:number, reasoningEfforts?:string[]}}} opts
107
65
  * @returns {{body?:object, warnings:string[], error?:{type:string,message:string}}}
108
66
  */
109
67
  export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
@@ -157,9 +115,9 @@ export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
157
115
  const rest = blocks.filter((b) => b && b.type !== 'tool_result');
158
116
  const relocated = [];
159
117
  for (const r of results) {
160
- const { text, images } = toolResultContent(r.content, caps, warn);
118
+ const { text, imageUrls } = flattenToolResult(r.content, caps, warn);
161
119
  messages.push({ role: 'tool', tool_call_id: String(r.tool_use_id || ''), content: text || (r.is_error ? 'error' : '') });
162
- relocated.push(...images);
120
+ relocated.push(...imageUrls.map((url) => ({ type: 'image_url', image_url: { url } })));
163
121
  }
164
122
  const parts = userParts(rest, caps, warn);
165
123
  parts.push(...relocated);
@@ -175,14 +133,7 @@ export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
175
133
  }
176
134
 
177
135
  // ── tools ──
178
- const referenced = new Set();
179
- for (const m of src) {
180
- if (!m || !Array.isArray(m.content)) continue;
181
- for (const b of m.content) {
182
- if (!b || b.type !== 'tool_result' || !Array.isArray(b.content)) continue;
183
- for (const r of b.content) if (r && r.type === 'tool_reference' && r.tool_name) referenced.add(String(r.tool_name));
184
- }
185
- }
136
+ const referenced = referencedToolNames(src);
186
137
  let tools;
187
138
  if (Array.isArray(body.tools) && body.tools.length) {
188
139
  if (caps.toolCalls === false) return fail(`model ${upstreamModel} does not support tool calls`);
@@ -190,8 +141,7 @@ export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
190
141
  for (const t of body.tools) {
191
142
  if (!t || typeof t !== 'object') continue;
192
143
  // Tool search: a deferred tool is sent only once a ToolSearch result has
193
- // referenced it (the Anthropic API expands tool_reference blocks against
194
- // these definitions server-side; chat/completions has no such step).
144
+ // referenced it (common.mjs#referencedToolNames).
195
145
  if (t.defer_loading === true && !referenced.has(String(t.name || ''))) continue;
196
146
  if (t.type && t.type !== 'custom' && SERVER_TOOL_RE.test(String(t.type))) {
197
147
  return fail(`tool ${JSON.stringify(t.name || t.type)} is an Anthropic server tool and cannot run through ${upstreamModel} (openai-chat bridge)`);
@@ -235,15 +185,8 @@ export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
235
185
  if (body.metadata !== undefined) warn('metadata');
236
186
 
237
187
  // ── reasoning ──
238
- let effort;
239
- if (body.thinking && typeof body.thinking === 'object' && body.thinking.type === 'enabled') {
240
- effort = budgetToReasoningEffort(body.thinking.budget_tokens);
241
- }
242
- const oc = body.output_config;
243
- if (oc && typeof oc === 'object' && typeof oc.effort === 'string' && EFFORT_TO_REASONING[oc.effort]) {
244
- effort = EFFORT_TO_REASONING[oc.effort];
245
- }
246
- if (effort) { if (reasoning) out.reasoning_effort = effort; else warn('thinking'); }
188
+ const effort = requestedEffort(body);
189
+ if (effort) { if (reasoning) out.reasoning_effort = mapEffort(effort, caps); else warn('thinking'); }
247
190
 
248
191
  // ── stream ──
249
192
  if (body.stream === true) { out.stream = true; out.stream_options = { include_usage: true }; }
@@ -0,0 +1,185 @@
1
+ // src/core/bridge/translate/responses-request.mjs
2
+ // Anthropic Messages API request -> OpenAI Responses API request
3
+ // (2026-09-23-bridge-openai-responses-design.md §7). Pure, same contract as
4
+ // toChatRequest: `{body, warnings}` or `{error}`. Stateless by design —
5
+ // `store: false` and the full history as `input` items every turn; the
6
+ // model's encrypted reasoning comes back inside the thinking blocks the CLI
7
+ // echoes (the reasoning marker, common.mjs) and is replayed as `reasoning`
8
+ // items for the SAME upstream model only.
9
+
10
+ import {
11
+ SERVER_TOOL_RE, textOf, imageUrl, flattenToolResult, referencedToolNames,
12
+ requestedEffort, mapEffort, decodeReasoningMarker,
13
+ } from './common.mjs';
14
+
15
+ /** OpenAI (and Copilot) reject max_output_tokens below this. */
16
+ const MIN_OUTPUT_TOKENS = 16;
17
+
18
+ /**
19
+ * Translate one Messages API request body.
20
+ * @param {object} body the Anthropic request as the CLI sent it
21
+ * @param {{upstreamModel:string, capabilities?:{toolCalls?:boolean, vision?:boolean, reasoning?:boolean, maxOutputTokens?:number, reasoningEfforts?:string[]}}} opts
22
+ * @returns {{body?:object, warnings:string[], error?:{type:string,message:string}}}
23
+ */
24
+ export function toResponsesRequest(body, { upstreamModel, capabilities = {} } = {}) {
25
+ const caps = capabilities || {};
26
+ const warnings = [];
27
+ const seen = new Set();
28
+ const warn = (what) => { if (what && !seen.has(what)) { seen.add(what); warnings.push(what); } };
29
+ const fail = (message) => ({ warnings, error: { type: 'invalid_request_error', message } });
30
+
31
+ if (!body || typeof body !== 'object') return fail('request body must be an object');
32
+
33
+ const input = [];
34
+ // Append a content part to the trailing message item of `role`, or open a new one.
35
+ const pushPart = (role, part) => {
36
+ const last = input[input.length - 1];
37
+ if (last && last.role === role && Array.isArray(last.content)) last.content.push(part);
38
+ else input.push({ role, content: [part] });
39
+ };
40
+
41
+ // ── system ──
42
+ const instructions = textOf(body.system);
43
+ if (Array.isArray(body.system) && body.system.some((b) => b && b.cache_control)) warn('cache_control');
44
+
45
+ // ── messages → input items ──
46
+ const src = Array.isArray(body.messages) ? body.messages : [];
47
+ for (const m of src) {
48
+ if (!m || typeof m !== 'object') continue;
49
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : (Array.isArray(m.content) ? m.content : []);
50
+ if (blocks.some((b) => b && b.cache_control)) warn('cache_control');
51
+
52
+ if (m.role === 'system') {
53
+ // The CLI's mid-conversation system message (beta mid-conversation-system).
54
+ const text = textOf(blocks);
55
+ if (text) input.push({ role: 'system', content: [{ type: 'input_text', text }] });
56
+ continue;
57
+ }
58
+
59
+ if (m.role === 'assistant') {
60
+ const turnStart = input.length;
61
+ for (const b of blocks) {
62
+ if (!b || typeof b !== 'object') continue;
63
+ if (b.type === 'text') {
64
+ if (typeof b.text === 'string' && b.text) pushPart('assistant', { type: 'output_text', text: b.text });
65
+ } else if (b.type === 'tool_use') {
66
+ input.push({ type: 'function_call', call_id: String(b.id || ''), name: String(b.name || ''), arguments: JSON.stringify(b.input ?? {}) });
67
+ } else if (b.type === 'thinking' || b.type === 'redacted_thinking') {
68
+ const mk = decodeReasoningMarker(b.type === 'thinking' ? b.signature : b.data);
69
+ if (mk && mk.model === upstreamModel) {
70
+ const text = b.type === 'thinking' && typeof b.thinking === 'string' ? b.thinking : '';
71
+ input.push({ type: 'reasoning', summary: text ? [{ type: 'summary_text', text }] : [], encrypted_content: mk.encrypted });
72
+ } else warn('thinking'); // another model's reasoning, or a real Claude signature
73
+ } else warn(b.type);
74
+ }
75
+ // A reasoning item must be followed by the message / call it was produced
76
+ // with — the upstream rejects one left dangling (a reasoning-only turn:
77
+ // output spent on reasoning, or cut by a filter). Drop it; the model
78
+ // re-reasons, as on the chat path, which drops thinking-only turns.
79
+ while (input.length > turnStart && input[input.length - 1].type === 'reasoning') { input.pop(); warn('thinking'); }
80
+ continue;
81
+ }
82
+
83
+ // user: tool outputs first (they answer the calls just made), then the
84
+ // rest of the message, then images relocated out of the tool results.
85
+ const results = blocks.filter((b) => b && b.type === 'tool_result');
86
+ const rest = blocks.filter((b) => b && b.type !== 'tool_result');
87
+ const relocated = [];
88
+ for (const r of results) {
89
+ const { text, imageUrls } = flattenToolResult(r.content, caps, warn);
90
+ input.push({ type: 'function_call_output', call_id: String(r.tool_use_id || ''), output: text || (r.is_error ? 'error' : '') });
91
+ relocated.push(...imageUrls);
92
+ }
93
+ for (const b of rest) {
94
+ if (!b || typeof b !== 'object') continue;
95
+ if (b.type === 'text') {
96
+ if (typeof b.text === 'string' && b.text) pushPart('user', { type: 'input_text', text: b.text });
97
+ } else if (b.type === 'image') {
98
+ if (caps.vision === false) {
99
+ warn('image');
100
+ pushPart('user', { type: 'input_text', text: '[image omitted: model has no vision]' });
101
+ } else {
102
+ const u = imageUrl(b);
103
+ if (u) pushPart('user', { type: 'input_image', image_url: u }); else warn('image');
104
+ }
105
+ } else if (b.type === 'document') {
106
+ warn('document');
107
+ pushPart('user', { type: 'input_text', text: '[document omitted: not supported by this model]' });
108
+ } else warn(b.type);
109
+ }
110
+ for (const u of relocated) pushPart('user', { type: 'input_image', image_url: u });
111
+ }
112
+
113
+ // ── tools ──
114
+ const referenced = referencedToolNames(src);
115
+ let tools;
116
+ if (Array.isArray(body.tools) && body.tools.length) {
117
+ if (caps.toolCalls === false) return fail(`model ${upstreamModel} does not support tool calls`);
118
+ tools = [];
119
+ for (const t of body.tools) {
120
+ if (!t || typeof t !== 'object') continue;
121
+ if (t.defer_loading === true && !referenced.has(String(t.name || ''))) continue;
122
+ if (t.type && t.type !== 'custom' && SERVER_TOOL_RE.test(String(t.type))) {
123
+ return fail(`tool ${JSON.stringify(t.name || t.type)} is an Anthropic server tool and cannot run through ${upstreamModel} (openai-responses bridge)`);
124
+ }
125
+ const tool = {
126
+ type: 'function',
127
+ name: String(t.name || ''),
128
+ parameters: t.input_schema && typeof t.input_schema === 'object' ? t.input_schema : { type: 'object', properties: {} },
129
+ strict: false,
130
+ };
131
+ if (typeof t.description === 'string' && t.description) tool.description = t.description;
132
+ tools.push(tool);
133
+ }
134
+ if (!tools.length) tools = undefined;
135
+ }
136
+
137
+ // ── tool_choice ──
138
+ let toolChoice;
139
+ let parallel;
140
+ const tc = body.tool_choice;
141
+ if (tc && typeof tc === 'object') {
142
+ if (tc.type === 'auto') toolChoice = 'auto';
143
+ else if (tc.type === 'any') toolChoice = 'required';
144
+ else if (tc.type === 'none') toolChoice = 'none';
145
+ else if (tc.type === 'tool' && tc.name) toolChoice = { type: 'function', name: String(tc.name) };
146
+ if (tc.disable_parallel_tool_use === true) parallel = false;
147
+ }
148
+
149
+ // ── assemble ──
150
+ const reasoning = caps.reasoning === true;
151
+ const out = { model: upstreamModel };
152
+ if (instructions) out.instructions = instructions;
153
+ out.input = input;
154
+ if (tools) out.tools = tools;
155
+ if (toolChoice !== undefined && tools) out.tool_choice = toolChoice;
156
+ if (parallel === false && tools) out.parallel_tool_calls = false;
157
+ out.store = false;
158
+
159
+ let maxTokens = Number(body.max_tokens);
160
+ if (Number.isFinite(maxTokens) && maxTokens > 0) {
161
+ const cap = Number(caps.maxOutputTokens);
162
+ if (Number.isFinite(cap) && cap > 0 && maxTokens > cap) { maxTokens = cap; warn('max_tokens clamped'); }
163
+ out.max_output_tokens = Math.max(MIN_OUTPUT_TOKENS, Math.floor(maxTokens));
164
+ }
165
+ if (typeof body.temperature === 'number') { if (reasoning) warn('temperature'); else out.temperature = body.temperature; }
166
+ if (typeof body.top_p === 'number') { if (reasoning) warn('top_p'); else out.top_p = body.top_p; }
167
+ if (body.top_k !== undefined) warn('top_k');
168
+ if (Array.isArray(body.stop_sequences) && body.stop_sequences.length) warn('stop_sequences'); // Responses has no `stop`
169
+ if (body.metadata !== undefined) warn('metadata');
170
+ // context_management (the CLI's thinking-clearing edits) is dropped silently:
171
+ // it rides every turn and has no upstream equivalent.
172
+
173
+ // ── reasoning: always ask for the summary and the encrypted reasoning, so
174
+ // every reasoning item can be replayed next turn (§8) ──
175
+ const effort = requestedEffort(body);
176
+ if (reasoning) {
177
+ out.reasoning = { summary: 'auto' };
178
+ if (effort) out.reasoning.effort = mapEffort(effort, caps);
179
+ out.include = ['reasoning.encrypted_content'];
180
+ } else if (effort) warn('thinking');
181
+
182
+ if (body.stream === true) out.stream = true;
183
+
184
+ return { body: out, warnings };
185
+ }