@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.
- package/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- 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
|
-
|
|
13
|
-
|
|
13
|
+
import {
|
|
14
|
+
SERVER_TOOL_RE, budgetToReasoningEffort, textOf, imageUrl, flattenToolResult,
|
|
15
|
+
referencedToolNames, requestedEffort, mapEffort,
|
|
16
|
+
} from './common.mjs';
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
const EFFORT_TO_REASONING = { low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' };
|
|
18
|
+
export { SERVER_TOOL_RE, budgetToReasoningEffort };
|
|
17
19
|
|
|
18
|
-
/**
|
|
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
|
|
40
|
-
|
|
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,
|
|
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(...
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
239
|
-
if (
|
|
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
|
+
}
|