@worca/app 1.3.0 → 1.4.0-rc.1
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 +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// src/core/ask-forms.mjs
|
|
2
|
+
// The two host-side gates of an ask form (spec §5).
|
|
3
|
+
//
|
|
4
|
+
// GATE 2 (ask time, prepareFormAsk): the agent wrote {form, data}. Resolve the
|
|
5
|
+
// form from its sidecar, cap the data, run P1's checkAskData (the data schema
|
|
6
|
+
// AND "the auto answer for THIS data must pass gate 3"), snapshot every file
|
|
7
|
+
// it references, resolve enumFrom/defaultFrom into a CLOSED answer schema, and
|
|
8
|
+
// hand back the ask the host gates on. A refusal carries the EXACT error list
|
|
9
|
+
// the agent is resumed with — never a throw.
|
|
10
|
+
//
|
|
11
|
+
// GATE 3 (answer time, formAnswerValidator): the human sent {values}. Drop the
|
|
12
|
+
// fields `when` hides, strip unknown keys, validate against the RESOLVED
|
|
13
|
+
// answer schema, and hand back {form, version, values}. A refusal carries the
|
|
14
|
+
// error list POST /api/answer returns as 422 — never a throw.
|
|
15
|
+
//
|
|
16
|
+
// Gate 3 runs off the PERSISTED ask alone (layout + answerSchema): by the time a
|
|
17
|
+
// server restart re-runs it, the agent's sidecar may have changed or its plugin
|
|
18
|
+
// been removed (§9). Nothing here reads the registry.
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
|
|
22
|
+
import { resolveAnswerSchema } from '../shared/forms/schema.mjs';
|
|
23
|
+
import { checkAskData, autoAnswer, collectAnswer, fileRefs } from '../shared/forms/answer.mjs';
|
|
24
|
+
import { FORM_SURFACES } from '../shared/forms/form-def.mjs';
|
|
25
|
+
import { snapshotAskFiles } from './ask-files.mjs';
|
|
26
|
+
|
|
27
|
+
/** The snapshot root inside a pipeline dir: <pipelineDir>/ask-files/<askId>/. */
|
|
28
|
+
export const ASK_FILES_DIR = 'ask-files';
|
|
29
|
+
|
|
30
|
+
/** Depth cap for anything that walks a posted value recursively. P1 caps only
|
|
31
|
+
* normalizeAskBlock (256 levels, form-def.mjs); its validate / collectAnswer /
|
|
32
|
+
* clone are recursive walkers and native JSON.stringify is one too — on Node 22
|
|
33
|
+
* a value ~9 000 levels deep throws RangeError out of collectAnswer, on Node 25
|
|
34
|
+
* it sails through into the DB and onto the socket. So BOTH gates count depth
|
|
35
|
+
* with a work list (never recursive) BEFORE any recursive step and refuse by
|
|
36
|
+
* name. Same figure as P1's block cap; it is not an ASK_LIMITS value. */
|
|
37
|
+
const MAX_JSON_DEPTH = 256;
|
|
38
|
+
|
|
39
|
+
/** Is `value` nested deeper than `max` object/array levels? Iterative on purpose. */
|
|
40
|
+
function nestsDeeperThan(value, max) {
|
|
41
|
+
const todo = [[value, 0]];
|
|
42
|
+
while (todo.length) {
|
|
43
|
+
const [cur, depth] = todo.pop();
|
|
44
|
+
if (cur === null || typeof cur !== 'object') continue;
|
|
45
|
+
if (depth > max) return true;
|
|
46
|
+
for (const child of Object.values(cur)) todo.push([child, depth + 1]);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
52
|
+
|
|
53
|
+
/** The ask id, reduced to the token that is BOTH the snapshot dir name and the
|
|
54
|
+
* file route's `:askId` segment. Deterministic and total: the real ask id is
|
|
55
|
+
* `questions-x:n_impl:1-r1`, which is neither a legal Windows path segment nor
|
|
56
|
+
* something a route may take as a path. */
|
|
57
|
+
export function askIdToken(id) {
|
|
58
|
+
const token = String(id ?? '').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96);
|
|
59
|
+
return token || 'ask';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gate 2. Never throws.
|
|
64
|
+
* @param {{agentMeta: object, payload: {form: string, data: unknown},
|
|
65
|
+
* cwd: string, pipelineDir: string, askId: string}} args
|
|
66
|
+
* @returns {Promise<{ok: true, ask: object, autoValues: object}|{ok: false, errors: Array}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function prepareFormAsk({ agentMeta, payload, cwd, pipelineDir, askId }) {
|
|
69
|
+
const forms = agentMeta?.ask?.forms || {};
|
|
70
|
+
const id = typeof payload?.form === 'string' ? payload.form.trim() : '';
|
|
71
|
+
const def = Object.hasOwn(forms, id) ? forms[id] : null;
|
|
72
|
+
if (!def) {
|
|
73
|
+
const known = Object.keys(forms);
|
|
74
|
+
return { ok: false, errors: [{ path: 'form', code: 'unknown-field',
|
|
75
|
+
message: `unknown form "${id}" — this agent declares ${known.length ? known.join(', ') : 'none'}` }] };
|
|
76
|
+
}
|
|
77
|
+
const data = payload?.data === undefined ? {} : payload.data;
|
|
78
|
+
// Gate 1 admits only a form with both object schemas; a def grafted past the
|
|
79
|
+
// registry may lack one, and resolveAnswerSchema(undefined) throws. Never throw.
|
|
80
|
+
if (!isObj(def.data) || !isObj(def.answer)) {
|
|
81
|
+
return { ok: false, errors: [{ path: 'form', code: 'unknown-field',
|
|
82
|
+
message: `form "${id}" has no data or answer schema` }] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The caps run FIRST, depth before bytes: a schema walk over a 50 MB blob is
|
|
86
|
+
// the denial of service the byte cap exists to prevent, and JSON.stringify
|
|
87
|
+
// (the byte count itself) is a recursive walker a deep value overflows on
|
|
88
|
+
// Node 22 — the depth count is iterative, so it goes first.
|
|
89
|
+
if (nestsDeeperThan(data, MAX_JSON_DEPTH)) {
|
|
90
|
+
return { ok: false, errors: [{ path: 'data', code: 'too-big',
|
|
91
|
+
message: `data is nested deeper than ${MAX_JSON_DEPTH} levels` }] };
|
|
92
|
+
}
|
|
93
|
+
let bytes;
|
|
94
|
+
try {
|
|
95
|
+
bytes = Buffer.byteLength(JSON.stringify(data ?? null) ?? 'null', 'utf8');
|
|
96
|
+
} catch {
|
|
97
|
+
return { ok: false, errors: [{ path: 'data', code: 'type', message: 'data must be JSON-serializable' }] };
|
|
98
|
+
}
|
|
99
|
+
if (bytes > ASK_LIMITS.dataBytes) {
|
|
100
|
+
return { ok: false, errors: [{ path: 'data', code: 'too-big',
|
|
101
|
+
message: `data is ${bytes} bytes; the limit is ${ASK_LIMITS.dataBytes}` }] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ruling X4 / P1 C13: NOT a bare validate(def.data, data). checkAskData also
|
|
105
|
+
// proves that the auto answer built from THIS data passes gate 3 — which is
|
|
106
|
+
// what makes autoAnswer() below total, and D10 ("auto can never produce an
|
|
107
|
+
// invalid answer") true at run time rather than only for `example`. Its error
|
|
108
|
+
// paths are already prefixed `data.` / `answer.`; never prefix them again.
|
|
109
|
+
const shape = checkAskData(def, data);
|
|
110
|
+
if (!shape.ok) return { ok: false, errors: shape.errors };
|
|
111
|
+
|
|
112
|
+
const token = askIdToken(askId);
|
|
113
|
+
// ONE fileRefs() call: its result feeds the snapshot AND becomes the envelope's
|
|
114
|
+
// `fileRefs` (ruling X16). Calling P1 twice would let the two arrays disagree,
|
|
115
|
+
// and the renderer's "is this bound value a file?" lookup depends on them
|
|
116
|
+
// lining up index for index.
|
|
117
|
+
const refs = fileRefs(def.data, data);
|
|
118
|
+
const { files, errors } = await snapshotAskFiles({
|
|
119
|
+
refs,
|
|
120
|
+
// Resolution order is the spec's: the node's working tree, then the pipeline
|
|
121
|
+
// dir (§7.1). Both are already absolute.
|
|
122
|
+
roots: [cwd, pipelineDir].filter(Boolean),
|
|
123
|
+
destDir: join(pipelineDir, ASK_FILES_DIR, token),
|
|
124
|
+
});
|
|
125
|
+
if (errors.length) return { ok: false, errors };
|
|
126
|
+
|
|
127
|
+
const ask = {
|
|
128
|
+
kind: 'form',
|
|
129
|
+
// The route-safe snapshot token. NOT the question id (ruling X1): the caller
|
|
130
|
+
// keeps that and passes it as `_ask({ id })`; only this ever reaches a URL.
|
|
131
|
+
askId: token,
|
|
132
|
+
form: id,
|
|
133
|
+
version: Number.isInteger(def.version) ? def.version : 1,
|
|
134
|
+
title: typeof def.title === 'string' && def.title.trim() ? def.title.trim() : id,
|
|
135
|
+
// 'any' | 'web' (P1 FORM_SURFACES, C5). P2 never branches on it — it rides the
|
|
136
|
+
// envelope and the persisted ask so P3 and P4 can. An unrecognized value fails
|
|
137
|
+
// safe to 'any', the surface that refuses nothing.
|
|
138
|
+
surface: FORM_SURFACES.includes(def.surface) ? def.surface : 'any',
|
|
139
|
+
data,
|
|
140
|
+
layout: Array.isArray(def.layout) ? def.layout : [],
|
|
141
|
+
// Resolved HERE and stored with the ask, so gate 3 validates against a closed
|
|
142
|
+
// set even after the sidecar changed (§3.1, §9).
|
|
143
|
+
answerSchema: resolveAnswerSchema(def.answer, data),
|
|
144
|
+
// X16: the renderer's file lookup table — every `type:'file'` value's data
|
|
145
|
+
// path, in the same order as `files`. `accept` is dropped: it is a gate-2
|
|
146
|
+
// input, not something any surface renders.
|
|
147
|
+
fileRefs: refs.map((r) => ({ path: r.path, rel: r.rel })),
|
|
148
|
+
files,
|
|
149
|
+
};
|
|
150
|
+
// D10's auto answer travels BESIDE the ask, never inside it: the persisted
|
|
151
|
+
// shape (§9) is exactly `ask` plus `values`.
|
|
152
|
+
return { ok: true, ask, autoValues: autoAnswer(def, data) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Gate 3, bound to one resolved ask. The returned validator is what
|
|
157
|
+
* RunHarness._ask hands to `pendingQuestion.validate`; a `{ ok: false }` result
|
|
158
|
+
* keeps the question OPEN and becomes the 422 body.
|
|
159
|
+
* @param {object} ask the resolved (or persisted) ask
|
|
160
|
+
* @returns {(payload: unknown) => {ok: true, payload: object}|{ok: false, errors: Array}}
|
|
161
|
+
*/
|
|
162
|
+
export function formAnswerValidator(ask) {
|
|
163
|
+
// R1: collectAnswer reads only `def.layout`, so a persisted ask is enough.
|
|
164
|
+
const def = { layout: Array.isArray(ask?.layout) ? ask.layout : [] };
|
|
165
|
+
const schema = ask?.answerSchema || { type: 'object', properties: {} };
|
|
166
|
+
const form = ask?.form;
|
|
167
|
+
const version = ask?.version;
|
|
168
|
+
return (payload) => {
|
|
169
|
+
const raw = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
170
|
+
&& payload.values && typeof payload.values === 'object' && !Array.isArray(payload.values)
|
|
171
|
+
? payload.values
|
|
172
|
+
: null;
|
|
173
|
+
if (!raw) {
|
|
174
|
+
return { ok: false, errors: [{ path: '', code: 'type', message: 'an answer is { values: { … } }' }] };
|
|
175
|
+
}
|
|
176
|
+
// Depth before collectAnswer: its clone() is a JSON round trip, which a deep
|
|
177
|
+
// value overflows on Node 22 — a RangeError out of answer(), not a 422.
|
|
178
|
+
if (nestsDeeperThan(raw, MAX_JSON_DEPTH)) {
|
|
179
|
+
return { ok: false, errors: [{ path: '', code: 'too-big', message: `values are nested deeper than ${MAX_JSON_DEPTH} levels` }] };
|
|
180
|
+
}
|
|
181
|
+
const { values, errors } = collectAnswer(def, schema, raw);
|
|
182
|
+
if (errors.length) return { ok: false, errors };
|
|
183
|
+
return { ok: true, payload: { form, version, values } };
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The generic question a twice-refused form downgrades to (§5 gate 2). The text
|
|
189
|
+
* is the form's own title — the host adds no prose of its own.
|
|
190
|
+
* @param {{form: string, title?: string}} args
|
|
191
|
+
*/
|
|
192
|
+
export function downgradeQuestion({ form, title }) {
|
|
193
|
+
const text = typeof title === 'string' && title.trim() ? title.trim() : String(form || 'form');
|
|
194
|
+
return { id: `form-${askIdToken(form)}`, question: text, options: [], allowFreeText: true };
|
|
195
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/core/ask-projection.mjs
|
|
2
|
+
// The two caps every non-web ask surface shares, and the one helper that renders a
|
|
3
|
+
// PERSISTED form ask's answer as text (spec D9, §9).
|
|
4
|
+
//
|
|
5
|
+
// The ask BODY is never formatted here: that is src/shared/forms/project.mjs's
|
|
6
|
+
// projectForm(). What lives here is what a pure, browser-safe module may not know —
|
|
7
|
+
// how many characters a chat message or an assistant read may spend — plus the
|
|
8
|
+
// values line, which is the named destination for the crude projection
|
|
9
|
+
// readPriorAnswers carries today.
|
|
10
|
+
import { promptFields, projectForm } from '../shared/forms/project.mjs';
|
|
11
|
+
|
|
12
|
+
/** Cap for Ask Worca's get_run_progress: an assistant read pays tokens per character. */
|
|
13
|
+
export const PROMPT_PROJECTION_MAX = 2000;
|
|
14
|
+
/** Cap for ONE chat message: Discord's 2000-char limit is the tightest shipped channel. */
|
|
15
|
+
export const CHAT_PROJECTION_MAX = 1400;
|
|
16
|
+
|
|
17
|
+
/** One answer VALUE as text. A review-list row is `<id>: <verdict> (<note>)`; nothing
|
|
18
|
+
* reaches String() as a bare object. */
|
|
19
|
+
function valueText(v) {
|
|
20
|
+
if (v === null || v === undefined) return '';
|
|
21
|
+
if (Array.isArray(v)) {
|
|
22
|
+
const rows = v.some((x) => x && typeof x === 'object' && !Array.isArray(x));
|
|
23
|
+
return v.map(valueText).join(rows ? '; ' : ', ');
|
|
24
|
+
}
|
|
25
|
+
if (typeof v === 'object') {
|
|
26
|
+
const id = v.id === undefined || v.id === null ? '' : String(v.id);
|
|
27
|
+
const verdict = v.verdict === undefined || v.verdict === null ? '' : String(v.verdict);
|
|
28
|
+
const note = v.note !== undefined && v.note !== null && String(v.note).trim()
|
|
29
|
+
? ` (${String(v.note).trim()})` : '';
|
|
30
|
+
return `${id}${id && verdict ? ': ' : ''}${verdict}${note}`;
|
|
31
|
+
}
|
|
32
|
+
return String(v);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A persisted form ask (ruling X3) as the two strings Ask Worca's get_run_progress
|
|
37
|
+
* reports (ruling X17): the text projection, capped at PROMPT_PROJECTION_MAX, and the
|
|
38
|
+
* answered values. null for a legacy round, whose questions/answers arrays carry
|
|
39
|
+
* everything as they always have. Unredacted — the tool applies its own `redact`,
|
|
40
|
+
* because free text from an agent is DATA, never instructions. A projection that
|
|
41
|
+
* cannot be built (a junk persisted row) degrades to the title, never throws.
|
|
42
|
+
* @param {object|null} ask
|
|
43
|
+
* @returns {{projection: string, values: string}|null}
|
|
44
|
+
*/
|
|
45
|
+
export function askProgress(ask) {
|
|
46
|
+
if (!ask || typeof ask !== 'object' || ask.kind !== 'form') return null;
|
|
47
|
+
let projection = '';
|
|
48
|
+
try { projection = projectForm(ask, { maxChars: PROMPT_PROJECTION_MAX }); } catch { projection = String(ask.title || ask.form || ''); }
|
|
49
|
+
return { projection, values: askValuesText(ask) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The answered values of a PERSISTED form ask (P2 E13: the resolved ask with `values`
|
|
54
|
+
* merged in), as `Label: value` lines in promptFields order. Keys promptFields does not
|
|
55
|
+
* know — a value left over from an older version of the form — are printed last by
|
|
56
|
+
* their field name rather than dropped. Always a string; '' when nothing was answered.
|
|
57
|
+
* @param {object|null} ask
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function askValuesText(ask) {
|
|
61
|
+
const values = ask && typeof ask === 'object' && ask.values && typeof ask.values === 'object'
|
|
62
|
+
? ask.values : null;
|
|
63
|
+
if (!values) return '';
|
|
64
|
+
let fields = [];
|
|
65
|
+
try { fields = promptFields(ask); } catch { fields = []; }
|
|
66
|
+
const labels = new Map(fields.map((f) => [f.field, f.label || f.field]));
|
|
67
|
+
const ordered = fields.map((f) => f.field).filter((k) => Object.hasOwn(values, k));
|
|
68
|
+
const rest = Object.keys(values).filter((k) => !labels.has(k));
|
|
69
|
+
const keys = [...ordered, ...rest];
|
|
70
|
+
if (!keys.length) return '';
|
|
71
|
+
return keys.map((k) => `${labels.get(k) || k}: ${valueText(values[k])}`).join('\n');
|
|
72
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/core/bridge/errors.mjs
|
|
2
|
+
// Upstream failures -> the Anthropic error envelope the CLI understands
|
|
3
|
+
// (model-bridge-design.md §5.7). The status AND the `error.type` both matter:
|
|
4
|
+
// the CLI's retry/backoff keys on the status, worca's classifyError keys on
|
|
5
|
+
// the message text (auth / rate_limit / ...), and the Test button's hints on
|
|
6
|
+
// both. Pure.
|
|
7
|
+
|
|
8
|
+
/** Hard payload ceiling before forwarding (Copilot's observed limit). */
|
|
9
|
+
export const PAYLOAD_CEILING_BYTES = 2_500_000;
|
|
10
|
+
|
|
11
|
+
/** The phrase Claude Code matches on to trigger its own compaction. */
|
|
12
|
+
export const PROMPT_TOO_LONG = 'prompt is too long';
|
|
13
|
+
|
|
14
|
+
const CONTEXT_RE = /context.?length|too long|maximum context|token limit|exceeds? .*tokens|too many tokens|request too large|payload too large/i;
|
|
15
|
+
|
|
16
|
+
/** Envelope for a status/type/message triple. */
|
|
17
|
+
export function anthropicError(status, type, message) {
|
|
18
|
+
return { status, body: { type: 'error', error: { type, message: String(message || type) } } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pull a human message out of an upstream error body (JSON or text). */
|
|
22
|
+
export function upstreamMessage(text) {
|
|
23
|
+
if (!text) return '';
|
|
24
|
+
try {
|
|
25
|
+
const j = JSON.parse(text);
|
|
26
|
+
const e = j && (j.error || j);
|
|
27
|
+
if (e && typeof e === 'object') return e.message || e.msg || e.code || JSON.stringify(e);
|
|
28
|
+
if (typeof e === 'string') return e;
|
|
29
|
+
} catch { /* not JSON */ }
|
|
30
|
+
return String(text).slice(0, 500);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map an upstream HTTP failure to the envelope.
|
|
35
|
+
* @param {number} status upstream status
|
|
36
|
+
* @param {string} text upstream body text
|
|
37
|
+
* @param {{provider?:string, retryAfter?:string}} [meta]
|
|
38
|
+
*/
|
|
39
|
+
export function mapUpstreamError(status, text, { provider = 'upstream', retryAfter } = {}) {
|
|
40
|
+
const msg = upstreamMessage(text);
|
|
41
|
+
const who = provider;
|
|
42
|
+
if (status === 401 || status === 403) {
|
|
43
|
+
return anthropicError(401, 'authentication_error', `${who}: authentication failed (${status})${msg ? ` — ${msg}` : ''}`);
|
|
44
|
+
}
|
|
45
|
+
if (status === 429) {
|
|
46
|
+
const e = anthropicError(429, 'rate_limit_error', `${who}: rate limited (429)${msg ? ` — ${msg}` : ''}`);
|
|
47
|
+
if (retryAfter) e.headers = { 'retry-after': String(retryAfter) };
|
|
48
|
+
return e;
|
|
49
|
+
}
|
|
50
|
+
if (status === 413 || (status === 400 && CONTEXT_RE.test(msg))) {
|
|
51
|
+
return anthropicError(400, 'invalid_request_error', PROMPT_TOO_LONG);
|
|
52
|
+
}
|
|
53
|
+
if (status === 404) {
|
|
54
|
+
return anthropicError(400, 'invalid_request_error', `${who}: model or endpoint not found (404)${msg ? ` — ${msg}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
if (status >= 400 && status < 500) {
|
|
57
|
+
return anthropicError(400, 'invalid_request_error', `${who}: request rejected (${status})${msg ? ` — ${msg}` : ''}`);
|
|
58
|
+
}
|
|
59
|
+
if (status === 503 || status === 529) {
|
|
60
|
+
return anthropicError(529, 'overloaded_error', `${who}: overloaded (${status})${msg ? ` — ${msg}` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
return anthropicError(502, 'api_error', `${who}: upstream error (${status})${msg ? ` — ${msg}` : ''}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A network-level failure (fetch threw) -> envelope. */
|
|
66
|
+
export function mapNetworkError(err, { provider = 'upstream' } = {}) {
|
|
67
|
+
const name = err && err.name;
|
|
68
|
+
if (name === 'AbortError') return anthropicError(499, 'api_error', `${provider}: request aborted`);
|
|
69
|
+
if (name === 'TimeoutError') return anthropicError(504, 'api_error', `${provider}: upstream timed out`);
|
|
70
|
+
const detail = err && (err.cause && err.cause.message || err.message) || String(err);
|
|
71
|
+
return anthropicError(502, 'api_error', `${provider}: endpoint unreachable — ${detail}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The bridge's own refusals (bad bearer, unknown model, feature gaps). */
|
|
75
|
+
export const bridgeErrors = {
|
|
76
|
+
unauthorized: () => anthropicError(401, 'authentication_error', 'bridge: invalid bearer token'),
|
|
77
|
+
unknownModel: (id) => anthropicError(404, 'not_found_error', `bridge: no bridged model ${JSON.stringify(id)} in the catalog`),
|
|
78
|
+
notSignedIn: (provider, hint) => anthropicError(401, 'authentication_error', `provider ${provider}: not signed in — ${hint}`),
|
|
79
|
+
termsNotAcknowledged: (provider) => anthropicError(401, 'authentication_error', `provider ${provider}: terms not acknowledged — open Settings › Providers`),
|
|
80
|
+
tooLarge: () => anthropicError(400, 'invalid_request_error', PROMPT_TOO_LONG),
|
|
81
|
+
badJson: () => anthropicError(400, 'invalid_request_error', 'bridge: request body is not JSON'),
|
|
82
|
+
unsupported: (message) => anthropicError(400, 'invalid_request_error', message),
|
|
83
|
+
notFound: () => anthropicError(404, 'not_found_error', 'bridge: unknown route'),
|
|
84
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// src/core/bridge/provider-ops.mjs
|
|
2
|
+
// The operations the Providers card and `worca models …` share
|
|
3
|
+
// (model-bridge-design.md §8.1, §8.4, §9): sign-in sessions for the Copilot
|
|
4
|
+
// device flow, sign-out, the provider state payload (never a token), the
|
|
5
|
+
// Copilot models list for import, the import itself, and a connection test
|
|
6
|
+
// for the key-based providers. Import contract: settings.mjs + the bridge's
|
|
7
|
+
// own modules; config.mjs imports NOTHING from here (the bridge is below it).
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
providerConfig, allProviders, updateProvider, resolveProviderSecret, providerSecretSet,
|
|
11
|
+
copilotTermsAcknowledged, acknowledgeCopilotTerms, clearCopilotSignIn,
|
|
12
|
+
listGlobalModels, addGlobalModel, updateGlobalModel,
|
|
13
|
+
} from '../settings.mjs';
|
|
14
|
+
import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDERS, isUpstreamBaseUrl } from '../model-env.mjs';
|
|
15
|
+
import {
|
|
16
|
+
startDeviceFlow, pollDeviceFlow, githubLogin, copilotToken, invalidateCopilotToken,
|
|
17
|
+
listCopilotModels, copilotUsage, catalogEntryForCopilotModel,
|
|
18
|
+
} from './providers/copilot.mjs';
|
|
19
|
+
import { keyOptional } from './registry.mjs';
|
|
20
|
+
import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
|
|
21
|
+
|
|
22
|
+
// ── Copilot sign-in sessions (in memory; a device code lives ~15 min) ────────
|
|
23
|
+
const sessions = new Map(); // deviceCode -> { startedAt, expiresAt, interval, lastPoll }
|
|
24
|
+
const SESSION_TTL_MS = 20 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function sweepSessions(now = Date.now()) {
|
|
27
|
+
for (const [k, s] of sessions) if (s.expiresAt < now) sessions.delete(k);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Begin a Copilot sign-in. Refuses until the terms are acknowledged (§8.2).
|
|
32
|
+
* @returns {Promise<{deviceCode, userCode, verificationUri, interval, expiresIn}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function beginCopilotLogin({ fetch: f } = {}) {
|
|
35
|
+
if (!copilotTermsAcknowledged()) {
|
|
36
|
+
throw Object.assign(new Error('acknowledge the GitHub Copilot notice before signing in'), { code: 'TERMS' });
|
|
37
|
+
}
|
|
38
|
+
sweepSessions();
|
|
39
|
+
const flow = await startDeviceFlow({ fetch: f });
|
|
40
|
+
sessions.set(flow.deviceCode, { startedAt: Date.now(), expiresAt: Date.now() + Math.min(flow.expiresIn * 1000, SESSION_TTL_MS), interval: flow.interval });
|
|
41
|
+
return flow;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One poll of a sign-in session. On approval the GitHub token and login are
|
|
46
|
+
* stored and the session ends.
|
|
47
|
+
* @returns {Promise<{pending:true, interval?:number}|{ok:true, login:string}|{error:string}>}
|
|
48
|
+
*/
|
|
49
|
+
export async function pollCopilotLogin(deviceCode, { fetch: f } = {}) {
|
|
50
|
+
sweepSessions();
|
|
51
|
+
const s = sessions.get(deviceCode);
|
|
52
|
+
if (!s) return { error: 'unknown or expired sign-in session — start again' };
|
|
53
|
+
const r = await pollDeviceFlow(deviceCode, { fetch: f });
|
|
54
|
+
if (r.pending) { if (r.interval) s.interval = r.interval; return { pending: true, interval: s.interval }; }
|
|
55
|
+
sessions.delete(deviceCode);
|
|
56
|
+
if (r.error) return { error: r.error };
|
|
57
|
+
let login = '';
|
|
58
|
+
try { login = await githubLogin(r.token, { fetch: f }); } catch { login = ''; }
|
|
59
|
+
// Prove the account has Copilot before storing: a token without Copilot
|
|
60
|
+
// access would only fail later, mid-run.
|
|
61
|
+
try {
|
|
62
|
+
await copilotToken(r.token, { fetch: f, force: true });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { error: err && err.code === 'EXCHANGE' ? `signed in as ${login || 'GitHub user'}, but the account has no Copilot access — ${err.message}` : (err.message || String(err)) };
|
|
65
|
+
}
|
|
66
|
+
await updateProvider('copilot', { githubToken: r.token, ...(login ? { login } : {}) });
|
|
67
|
+
return { ok: true, login };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Sign out of Copilot: forget the token, drop the cached exchange. */
|
|
71
|
+
export async function copilotLogout() {
|
|
72
|
+
const cfg = providerConfig('copilot');
|
|
73
|
+
const tok = resolveProviderSecret(cfg.githubToken);
|
|
74
|
+
if (tok) invalidateCopilotToken(tok);
|
|
75
|
+
await clearCopilotSignIn();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Record the terms acknowledgement (§8.2). */
|
|
79
|
+
export async function acknowledgeTerms() { return acknowledgeCopilotTerms(); }
|
|
80
|
+
|
|
81
|
+
// ── state payloads (never a secret) ──────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const secretSource = (v) => (!v ? null : modelEnvRef(v) ? 'env' : 'stored');
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The Providers card payload (§9). `quota` is fetched only when asked — it is
|
|
87
|
+
* a network call to GitHub — and is null when the account exposes none.
|
|
88
|
+
* @param {{quota?:boolean, fetch?:typeof fetch}} [opts]
|
|
89
|
+
*/
|
|
90
|
+
export async function providersState({ quota = false, fetch: f } = {}) {
|
|
91
|
+
const all = allProviders();
|
|
92
|
+
const c = all.copilot;
|
|
93
|
+
const token = resolveProviderSecret(c.githubToken);
|
|
94
|
+
const copilot = {
|
|
95
|
+
connected: !!token,
|
|
96
|
+
login: c.login || null,
|
|
97
|
+
accountType: c.accountType,
|
|
98
|
+
acknowledgedTerms: c.acknowledgedTerms || null,
|
|
99
|
+
termsCurrent: copilotTermsAcknowledged(),
|
|
100
|
+
termsVersion: COPILOT_TERMS_VERSION,
|
|
101
|
+
maxConcurrent: c.maxConcurrent,
|
|
102
|
+
tokenSource: secretSource(c.githubToken),
|
|
103
|
+
tokenRef: modelEnvRef(c.githubToken) ? c.githubToken : null,
|
|
104
|
+
quota: null,
|
|
105
|
+
};
|
|
106
|
+
if (quota && token) {
|
|
107
|
+
try { copilot.quota = await copilotUsage(token, { fetch: f }); } catch { copilot.quota = null; }
|
|
108
|
+
}
|
|
109
|
+
const keyed = (name) => {
|
|
110
|
+
const p = all[name];
|
|
111
|
+
return {
|
|
112
|
+
configured: !!resolveProviderSecret(p.apiKey),
|
|
113
|
+
keyOptional: keyOptional(name, p.baseUrl),
|
|
114
|
+
keySet: providerSecretSet(name),
|
|
115
|
+
keySource: secretSource(p.apiKey),
|
|
116
|
+
keyRef: modelEnvRef(p.apiKey) ? p.apiKey : null,
|
|
117
|
+
keyMasked: p.apiKey && !modelEnvRef(p.apiKey) ? maskModelEnvValue(p.apiKey) : null,
|
|
118
|
+
baseUrl: p.baseUrl,
|
|
119
|
+
maxConcurrent: p.maxConcurrent,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic') };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
|
|
126
|
+
export async function patchProvider(name, patch = {}) {
|
|
127
|
+
if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
|
|
128
|
+
const p = { ...patch };
|
|
129
|
+
for (const k of ['apiKey', 'githubToken']) if (typeof p[k] === 'string' && p[k].startsWith('••')) delete p[k];
|
|
130
|
+
return updateProvider(name, p);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Copilot models: list for import, import ─────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** Copilot's models for the import sheet, each with `inCatalog` (§8.4). */
|
|
136
|
+
export async function copilotModelsForImport({ fetch: f } = {}) {
|
|
137
|
+
const c = providerConfig('copilot');
|
|
138
|
+
const token = resolveProviderSecret(c.githubToken);
|
|
139
|
+
if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
|
|
140
|
+
const list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
|
|
141
|
+
const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
|
|
142
|
+
return list.map((m) => ({ ...m, catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
|
|
143
|
+
.sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Import Copilot models into the catalog (§8.4). A new id gets the full
|
|
148
|
+
* import shape; an existing `copilot-*` id only has its capabilities (and api,
|
|
149
|
+
* should the vendor route change) refreshed — a user-edited label, efforts or
|
|
150
|
+
* pricing is never overwritten.
|
|
151
|
+
* @param {string[]} ids Copilot model ids (not catalog ids)
|
|
152
|
+
* @returns {Promise<{created:string[], updated:string[], skipped:string[]}>}
|
|
153
|
+
*/
|
|
154
|
+
export async function importCopilotModels(ids, { fetch: f } = {}) {
|
|
155
|
+
const wanted = new Set((Array.isArray(ids) ? ids : []).map((s) => String(s)));
|
|
156
|
+
if (!wanted.size) throw new Error('pick at least one model to import');
|
|
157
|
+
const list = await copilotModelsForImport({ fetch: f });
|
|
158
|
+
const byId = new Map(list.map((m) => [m.id, m]));
|
|
159
|
+
const created = []; const updated = []; const skipped = [];
|
|
160
|
+
for (const id of wanted) {
|
|
161
|
+
const m = byId.get(id);
|
|
162
|
+
if (!m) { skipped.push(id); continue; }
|
|
163
|
+
const entry = catalogEntryForCopilotModel(m);
|
|
164
|
+
if (m.inCatalog) {
|
|
165
|
+
const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
|
|
166
|
+
if (!current || !current.upstream || current.upstream.provider !== 'copilot') { skipped.push(id); continue; }
|
|
167
|
+
await updateGlobalModel(current.id, { upstream: { ...current.upstream, api: entry.upstream.api, model: entry.upstream.model, capabilities: entry.upstream.capabilities } });
|
|
168
|
+
updated.push(current.id);
|
|
169
|
+
} else {
|
|
170
|
+
await addGlobalModel(entry);
|
|
171
|
+
created.push(entry.id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { created, updated, skipped };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── OpenAI-compatible endpoints: discovery, import ──────────────────────────
|
|
178
|
+
|
|
179
|
+
/** The provider's own base URL when the caller names none, trailing slash trimmed. */
|
|
180
|
+
function endpointBase(baseUrl) {
|
|
181
|
+
const b = String(baseUrl || '').trim() || providerConfig('openai').baseUrl || '';
|
|
182
|
+
if (!isUpstreamBaseUrl(b)) throw new Error('baseUrl must be an http(s) URL with no query or fragment');
|
|
183
|
+
return b.replace(/\/+$/, '');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What an OpenAI-compatible endpoint serves, each row with `catalogId` and `inCatalog` (§8.4's
|
|
188
|
+
* Copilot import, for a server you run: llama.cpp, Ollama, LM Studio, vLLM, a gateway).
|
|
189
|
+
* @param {{baseUrl?:string, fetch?:typeof fetch}} [opts]
|
|
190
|
+
*/
|
|
191
|
+
export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
|
|
192
|
+
const base = endpointBase(baseUrl);
|
|
193
|
+
const p = providerConfig('openai');
|
|
194
|
+
const key = resolveProviderSecret(p.apiKey);
|
|
195
|
+
const out = await listEndpointModels(base, { apiKey: key, fetch: f });
|
|
196
|
+
const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
|
|
197
|
+
return {
|
|
198
|
+
...out,
|
|
199
|
+
models: out.models.map((m) => {
|
|
200
|
+
const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
|
|
201
|
+
const usable = importableModel(m);
|
|
202
|
+
return { ...m, catalogId: entry.id, inCatalog: have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
|
|
209
|
+
* label, efforts and pricing you edited and only has its upstream refreshed — the Copilot rule.
|
|
210
|
+
* @param {string[]} ids model ids as the endpoint reports them (not catalog ids)
|
|
211
|
+
* @returns {Promise<{created:string[], updated:string[], skipped:Array<{id:string, why:string}>, server:string, baseUrl:string}>}
|
|
212
|
+
*/
|
|
213
|
+
export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
214
|
+
const wanted = new Set((Array.isArray(ids) ? ids : []).map((s) => String(s)));
|
|
215
|
+
if (!wanted.size) throw new Error('pick at least one model to import');
|
|
216
|
+
const out = await endpointModelsForImport({ baseUrl, fetch: f });
|
|
217
|
+
const byId = new Map(out.models.map((m) => [m.id, m]));
|
|
218
|
+
const p = providerConfig('openai');
|
|
219
|
+
const created = []; const updated = []; const skipped = [];
|
|
220
|
+
for (const id of wanted) {
|
|
221
|
+
const m = byId.get(id);
|
|
222
|
+
if (!m) { skipped.push({ id, why: 'the endpoint does not serve it' }); continue; }
|
|
223
|
+
if (!m.importable) { skipped.push({ id, why: m.blocked || 'not usable in a pipeline' }); continue; }
|
|
224
|
+
const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
|
|
225
|
+
const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
|
|
226
|
+
if (current) {
|
|
227
|
+
if (!current.upstream || current.upstream.provider !== 'openai') { skipped.push({ id, why: `"${current.id}" already exists and is not an OpenAI-compatible entry` }); continue; }
|
|
228
|
+
await updateGlobalModel(current.id, { upstream: { ...current.upstream, model: entry.upstream.model, ...(entry.upstream.baseUrl ? { baseUrl: entry.upstream.baseUrl } : {}), ...(entry.upstream.capabilities ? { capabilities: entry.upstream.capabilities } : {}) } });
|
|
229
|
+
updated.push(current.id);
|
|
230
|
+
} else {
|
|
231
|
+
await addGlobalModel(entry);
|
|
232
|
+
created.push(entry.id);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { created, updated, skipped, server: out.server, serverLabel: out.serverLabel, baseUrl: out.baseUrl, warnings: out.warnings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── key-based providers: connection test ────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A cheap reachability + auth check for openai / anthropic (§8.1): GET the models list with the
|
|
242
|
+
* configured key. Never throws.
|
|
243
|
+
*
|
|
244
|
+
* `baseUrl` / `apiKey` test values that are NOT stored yet — what the user has typed into the
|
|
245
|
+
* Providers card. Testing the stored ones instead made the button lie: type a local llama.cpp URL,
|
|
246
|
+
* press Test, and the answer was "no API key configured", because it had tested api.openai.com.
|
|
247
|
+
* A masked echo (••…) means "keep what is stored" exactly as a save does.
|
|
248
|
+
* @returns {Promise<{ok:true, models?:number}|{ok:false, message:string}>}
|
|
249
|
+
*/
|
|
250
|
+
export async function testProviderConnection(name, { fetch: f = globalThis.fetch, baseUrl = '', apiKey } = {}) {
|
|
251
|
+
if (name === 'copilot') {
|
|
252
|
+
const c = providerConfig('copilot');
|
|
253
|
+
const token = resolveProviderSecret(c.githubToken);
|
|
254
|
+
if (!token) return { ok: false, message: 'not signed in' };
|
|
255
|
+
try { await copilotToken(token, { fetch: f, force: true }); return { ok: true }; } catch (err) { return { ok: false, message: err.message || String(err) }; }
|
|
256
|
+
}
|
|
257
|
+
if (!UPSTREAM_PROVIDERS.includes(name)) return { ok: false, message: `unknown provider ${name}` };
|
|
258
|
+
const stored = providerConfig(name);
|
|
259
|
+
const typedKey = typeof apiKey === 'string' && !apiKey.startsWith('••') ? apiKey.trim() : null;
|
|
260
|
+
const p = {
|
|
261
|
+
...stored,
|
|
262
|
+
...(baseUrl && isUpstreamBaseUrl(baseUrl) ? { baseUrl: baseUrl.trim().replace(/\/+$/, '') } : {}),
|
|
263
|
+
...(typedKey === null ? {} : { apiKey: typedKey }),
|
|
264
|
+
};
|
|
265
|
+
const key = resolveProviderSecret(p.apiKey);
|
|
266
|
+
const keyIsSet = typedKey === null ? providerSecretSet(name) : !!typedKey;
|
|
267
|
+
if (!key && (keyIsSet || !keyOptional(name, p.baseUrl))) return { ok: false, message: keyIsSet ? 'the key\'s ${VAR} is not set in worca\'s environment' : `no API key configured for ${p.baseUrl}` };
|
|
268
|
+
const base = (p.baseUrl || '').replace(/\/+$/, '');
|
|
269
|
+
const url = name === 'anthropic' ? (/\/v1$/.test(base) ? `${base}/models` : `${base}/v1/models`) : `${base}/models`;
|
|
270
|
+
const headers = name === 'anthropic' ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : (key ? { authorization: `Bearer ${key}` } : {});
|
|
271
|
+
try {
|
|
272
|
+
const r = await f(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
273
|
+
if (r.status === 401 || r.status === 403) return { ok: false, message: `authentication failed (${r.status})` };
|
|
274
|
+
if (!r.ok) return { ok: false, message: `endpoint answered ${r.status}` };
|
|
275
|
+
const j = await r.json().catch(() => null);
|
|
276
|
+
const n = j && Array.isArray(j.data) ? j.data.length : undefined;
|
|
277
|
+
return { ok: true, ...(n !== undefined ? { models: n } : {}) };
|
|
278
|
+
} catch (err) {
|
|
279
|
+
return { ok: false, message: `endpoint unreachable — ${err && err.message ? err.message : String(err)}` };
|
|
280
|
+
}
|
|
281
|
+
}
|