@worca/app 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,269 @@
|
|
|
1
|
+
// src/core/bridge/providers/copilot.mjs
|
|
2
|
+
// GitHub Copilot as a bridge provider (model-bridge-design.md §7): the device
|
|
3
|
+
// flow sign-in, the GitHub-token → short-lived Copilot-token exchange (cached
|
|
4
|
+
// in memory, never on disk), the request headers Copilot's gateway expects,
|
|
5
|
+
// the models list and the quota snapshot.
|
|
6
|
+
//
|
|
7
|
+
// The client id, the token-exchange endpoint and the editor headers are the
|
|
8
|
+
// values every community bridge sends (ericc-ch/copilot-api, MIT — see
|
|
9
|
+
// THIRD_PARTY_NOTICES.md). Worca identifies itself the same way because the
|
|
10
|
+
// gateway serves no other client; the user acknowledges that once (§8.2).
|
|
11
|
+
//
|
|
12
|
+
// Every network call takes an injectable `fetch` so the whole module is
|
|
13
|
+
// testable against a stub without touching github.com.
|
|
14
|
+
|
|
15
|
+
export const GITHUB_CLIENT_ID = 'Iv1.b507a08c87ecfe98';
|
|
16
|
+
export const GITHUB_SCOPES = 'read:user';
|
|
17
|
+
export const GITHUB_BASE = 'https://github.com';
|
|
18
|
+
export const GITHUB_API = 'https://api.github.com';
|
|
19
|
+
export const COPILOT_API_VERSION = '2025-04-01';
|
|
20
|
+
const EDITOR_VERSION = 'vscode/1.104.0';
|
|
21
|
+
const PLUGIN_VERSION = 'copilot-chat/0.31.0';
|
|
22
|
+
const USER_AGENT = 'GitHubCopilotChat/0.31.0';
|
|
23
|
+
|
|
24
|
+
/** The API host for an account type when the token exchange names none. */
|
|
25
|
+
export function copilotApiHost(accountType) {
|
|
26
|
+
if (accountType === 'business') return 'https://api.business.githubcopilot.com';
|
|
27
|
+
if (accountType === 'enterprise') return 'https://api.enterprise.githubcopilot.com';
|
|
28
|
+
return 'https://api.githubcopilot.com';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Headers for github.com / api.github.com calls (device flow, exchange, user). */
|
|
32
|
+
export function githubHeaders(githubToken) {
|
|
33
|
+
return {
|
|
34
|
+
'content-type': 'application/json',
|
|
35
|
+
accept: 'application/json',
|
|
36
|
+
...(githubToken ? { authorization: `token ${githubToken}` } : {}),
|
|
37
|
+
'editor-version': EDITOR_VERSION,
|
|
38
|
+
'editor-plugin-version': PLUGIN_VERSION,
|
|
39
|
+
'user-agent': USER_AGENT,
|
|
40
|
+
'x-github-api-version': COPILOT_API_VERSION,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Headers for a Copilot gateway call (§7.1).
|
|
46
|
+
* @param {string} copilotToken
|
|
47
|
+
* @param {{vision?:boolean, initiator?:'user'|'agent', requestId?:string}} [opts]
|
|
48
|
+
*/
|
|
49
|
+
export function copilotHeaders(copilotToken, { vision = false, initiator = 'user', requestId } = {}) {
|
|
50
|
+
return {
|
|
51
|
+
authorization: `Bearer ${copilotToken}`,
|
|
52
|
+
'content-type': 'application/json',
|
|
53
|
+
'copilot-integration-id': 'vscode-chat',
|
|
54
|
+
'editor-version': EDITOR_VERSION,
|
|
55
|
+
'editor-plugin-version': PLUGIN_VERSION,
|
|
56
|
+
'user-agent': USER_AGENT,
|
|
57
|
+
'openai-intent': 'conversation-panel',
|
|
58
|
+
'x-github-api-version': COPILOT_API_VERSION,
|
|
59
|
+
'x-request-id': requestId || cryptoRandomId(),
|
|
60
|
+
'x-initiator': initiator === 'agent' ? 'agent' : 'user',
|
|
61
|
+
...(vision ? { 'copilot-vision-request': 'true' } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cryptoRandomId() {
|
|
66
|
+
// UUID v4 without importing crypto at module load (keeps the leaf cheap).
|
|
67
|
+
const h = [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
68
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether a Messages request carries an image (→ copilot-vision-request). */
|
|
72
|
+
export function bodyHasImage(body) {
|
|
73
|
+
const msgs = body && Array.isArray(body.messages) ? body.messages : [];
|
|
74
|
+
for (const m of msgs) {
|
|
75
|
+
if (!Array.isArray(m?.content)) continue;
|
|
76
|
+
for (const b of m.content) {
|
|
77
|
+
if (b?.type === 'image') return true;
|
|
78
|
+
if (b?.type === 'tool_result' && Array.isArray(b.content) && b.content.some((x) => x?.type === 'image')) return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 'agent' when the last message continues a tool loop, else 'user' (§7.1). */
|
|
85
|
+
export function requestInitiator(body) {
|
|
86
|
+
const msgs = body && Array.isArray(body.messages) ? body.messages : [];
|
|
87
|
+
const last = msgs[msgs.length - 1];
|
|
88
|
+
if (!last) return 'user';
|
|
89
|
+
if (last.role === 'assistant') return 'agent';
|
|
90
|
+
if (Array.isArray(last.content) && last.content.some((b) => b?.type === 'tool_result')) return 'agent';
|
|
91
|
+
return 'user';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── device flow ──────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the GitHub device flow. Returns the code the user types at
|
|
98
|
+
* github.com/login/device plus the poll interval.
|
|
99
|
+
* @param {{fetch?:typeof fetch}} [deps]
|
|
100
|
+
*/
|
|
101
|
+
export async function startDeviceFlow({ fetch: f = globalThis.fetch } = {}) {
|
|
102
|
+
const r = await f(`${GITHUB_BASE}/login/device/code`, {
|
|
103
|
+
method: 'POST', headers: githubHeaders(),
|
|
104
|
+
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: GITHUB_SCOPES }),
|
|
105
|
+
});
|
|
106
|
+
if (!r.ok) throw new Error(`GitHub device flow could not start (${r.status})`);
|
|
107
|
+
const j = await r.json();
|
|
108
|
+
if (!j.device_code || !j.user_code) throw new Error('GitHub device flow: malformed response');
|
|
109
|
+
return {
|
|
110
|
+
deviceCode: j.device_code,
|
|
111
|
+
userCode: j.user_code,
|
|
112
|
+
verificationUri: j.verification_uri || `${GITHUB_BASE}/login/device`,
|
|
113
|
+
interval: Number(j.interval) || 5,
|
|
114
|
+
expiresIn: Number(j.expires_in) || 900,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One poll of the device flow. `{pending:true}` while the user has not
|
|
120
|
+
* approved yet (also for slow_down, with `interval` raised), `{ok:true, token}`
|
|
121
|
+
* once approved, `{error}` when the code expired or was denied.
|
|
122
|
+
* @param {string} deviceCode
|
|
123
|
+
* @param {{fetch?:typeof fetch}} [deps]
|
|
124
|
+
*/
|
|
125
|
+
export async function pollDeviceFlow(deviceCode, { fetch: f = globalThis.fetch } = {}) {
|
|
126
|
+
const r = await f(`${GITHUB_BASE}/login/oauth/access_token`, {
|
|
127
|
+
method: 'POST', headers: githubHeaders(),
|
|
128
|
+
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
|
|
129
|
+
});
|
|
130
|
+
const j = await r.json().catch(() => ({}));
|
|
131
|
+
if (j.access_token) return { ok: true, token: j.access_token };
|
|
132
|
+
if (j.error === 'authorization_pending') return { pending: true };
|
|
133
|
+
if (j.error === 'slow_down') return { pending: true, interval: Number(j.interval) || 10 };
|
|
134
|
+
if (j.error === 'expired_token') return { error: 'the device code expired — start the sign-in again' };
|
|
135
|
+
if (j.error === 'access_denied') return { error: 'the sign-in was denied on github.com' };
|
|
136
|
+
return { error: j.error_description || j.error || `GitHub answered ${r.status}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The GitHub login of the token's user (display only). */
|
|
140
|
+
export async function githubLogin(githubToken, { fetch: f = globalThis.fetch } = {}) {
|
|
141
|
+
const r = await f(`${GITHUB_API}/user`, { headers: githubHeaders(githubToken) });
|
|
142
|
+
if (!r.ok) throw new Error(`GitHub /user answered ${r.status}`);
|
|
143
|
+
const j = await r.json();
|
|
144
|
+
return typeof j.login === 'string' ? j.login : '';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── token exchange (memory cache) ────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const REFRESH_MARGIN_S = 60;
|
|
150
|
+
const cache = new Map(); // githubToken -> { token, expiresAt (ms), apiHost }
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A live Copilot token for `githubToken`, exchanged on first use and refreshed
|
|
154
|
+
* before expiry. Returns `{token, apiHost}`; `apiHost` is the gateway the
|
|
155
|
+
* exchange named (`endpoints.api`) or null.
|
|
156
|
+
* @param {string} githubToken
|
|
157
|
+
* @param {{fetch?:typeof fetch, now?:() => number, force?:boolean}} [deps]
|
|
158
|
+
*/
|
|
159
|
+
export async function copilotToken(githubToken, { fetch: f = globalThis.fetch, now = Date.now, force = false } = {}) {
|
|
160
|
+
if (!githubToken) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
|
|
161
|
+
const hit = cache.get(githubToken);
|
|
162
|
+
if (!force && hit && hit.expiresAt - now() > REFRESH_MARGIN_S * 1000) return { token: hit.token, apiHost: hit.apiHost };
|
|
163
|
+
const r = await f(`${GITHUB_API}/copilot_internal/v2/token`, { headers: githubHeaders(githubToken) });
|
|
164
|
+
if (r.status === 401 || r.status === 403) {
|
|
165
|
+
cache.delete(githubToken);
|
|
166
|
+
throw Object.assign(new Error(`GitHub rejected the stored token (${r.status}) — sign in again`), { code: 'AUTH', status: r.status });
|
|
167
|
+
}
|
|
168
|
+
if (!r.ok) throw Object.assign(new Error(`Copilot token exchange answered ${r.status}`), { code: 'EXCHANGE', status: r.status });
|
|
169
|
+
const j = await r.json();
|
|
170
|
+
if (!j.token) throw Object.assign(new Error('Copilot token exchange: no token in the response (is Copilot enabled for this account?)'), { code: 'EXCHANGE' });
|
|
171
|
+
const expiresAt = Number(j.expires_at) > 0 ? Number(j.expires_at) * 1000 : now() + 25 * 60 * 1000;
|
|
172
|
+
const apiHost = j.endpoints && typeof j.endpoints.api === 'string' ? j.endpoints.api.replace(/\/+$/, '') : null;
|
|
173
|
+
cache.set(githubToken, { token: j.token, expiresAt, apiHost });
|
|
174
|
+
return { token: j.token, apiHost };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Drop a cached Copilot token (after a 401 from the gateway). */
|
|
178
|
+
export function invalidateCopilotToken(githubToken) { cache.delete(githubToken); }
|
|
179
|
+
/** Test hook. */
|
|
180
|
+
export function _resetCopilotCache() { cache.clear(); }
|
|
181
|
+
|
|
182
|
+
// ── models + quota ───────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const REASONING_ID_RE = /^(o\d|gpt-5|codex|.*-thinking|.*reasoning)/i;
|
|
185
|
+
|
|
186
|
+
/** Normalize one entry of Copilot's /models list to worca's shape (§8.4). */
|
|
187
|
+
export function normalizeCopilotModel(m) {
|
|
188
|
+
if (!m || typeof m !== 'object' || typeof m.id !== 'string') return null;
|
|
189
|
+
const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
|
|
190
|
+
const supports = caps.supports && typeof caps.supports === 'object' ? caps.supports : {};
|
|
191
|
+
const limits = caps.limits && typeof caps.limits === 'object' ? caps.limits : {};
|
|
192
|
+
const vendor = typeof m.vendor === 'string' ? m.vendor : '';
|
|
193
|
+
const reasoning = supports.reasoning_effort === true || supports.thinking === true || REASONING_ID_RE.test(m.id);
|
|
194
|
+
return {
|
|
195
|
+
id: m.id,
|
|
196
|
+
name: typeof m.name === 'string' && m.name ? m.name : m.id,
|
|
197
|
+
vendor,
|
|
198
|
+
family: typeof caps.family === 'string' ? caps.family : '',
|
|
199
|
+
type: typeof caps.type === 'string' ? caps.type : '',
|
|
200
|
+
preview: m.preview === true,
|
|
201
|
+
pickerEnabled: m.model_picker_enabled !== false,
|
|
202
|
+
policyState: m.policy && typeof m.policy === 'object' && typeof m.policy.state === 'string' ? m.policy.state : 'enabled',
|
|
203
|
+
toolCalls: supports.tool_calls !== false,
|
|
204
|
+
vision: supports.vision === true,
|
|
205
|
+
reasoning,
|
|
206
|
+
maxPromptTokens: Number(limits.max_prompt_tokens) || null,
|
|
207
|
+
maxOutputTokens: Number(limits.max_output_tokens) || null,
|
|
208
|
+
contextWindow: Number(limits.max_context_window_tokens) || null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether a normalized Copilot model is an Anthropic (Claude) one → native passthrough. */
|
|
213
|
+
export function isAnthropicVendor(m) {
|
|
214
|
+
return /anthropic/i.test(m.vendor || '') || /^claude/i.test(m.id || '');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The catalog entry an import creates for a Copilot model (§8.4). */
|
|
218
|
+
export function catalogEntryForCopilotModel(m) {
|
|
219
|
+
const anthropic = isAnthropicVendor(m);
|
|
220
|
+
const capabilities = {
|
|
221
|
+
toolCalls: m.toolCalls, vision: m.vision, reasoning: m.reasoning,
|
|
222
|
+
...(m.maxPromptTokens ? { maxPromptTokens: m.maxPromptTokens } : {}),
|
|
223
|
+
...(m.maxOutputTokens ? { maxOutputTokens: m.maxOutputTokens } : {}),
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
id: `copilot-${m.id}`,
|
|
227
|
+
label: `${m.name} (Copilot)`,
|
|
228
|
+
efforts: anthropic || m.reasoning ? undefined : ['medium'],
|
|
229
|
+
upstream: { provider: 'copilot', api: anthropic ? 'anthropic' : 'openai-chat', model: m.id, capabilities },
|
|
230
|
+
cost: { free: true },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The Copilot models list, normalized. Chat models only.
|
|
236
|
+
* @param {string} githubToken
|
|
237
|
+
* @param {{accountType?:string, fetch?:typeof fetch}} [deps]
|
|
238
|
+
*/
|
|
239
|
+
export async function listCopilotModels(githubToken, { accountType = 'individual', fetch: f = globalThis.fetch } = {}) {
|
|
240
|
+
const { token, apiHost } = await copilotToken(githubToken, { fetch: f });
|
|
241
|
+
const host = apiHost || copilotApiHost(accountType);
|
|
242
|
+
const r = await f(`${host}/models`, { headers: copilotHeaders(token) });
|
|
243
|
+
if (!r.ok) throw Object.assign(new Error(`Copilot /models answered ${r.status}`), { status: r.status });
|
|
244
|
+
const j = await r.json();
|
|
245
|
+
const data = Array.isArray(j.data) ? j.data : (Array.isArray(j) ? j : []);
|
|
246
|
+
return data.map(normalizeCopilotModel).filter((m) => m && (!m.type || m.type === 'chat'));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The premium-request quota snapshot, or null when the account exposes none.
|
|
251
|
+
* @returns {Promise<{used:number|null, entitlement:number|null, remaining:number|null, percentRemaining:number|null, unlimited:boolean, resetDate:string|null}|null>}
|
|
252
|
+
*/
|
|
253
|
+
export async function copilotUsage(githubToken, { fetch: f = globalThis.fetch } = {}) {
|
|
254
|
+
const r = await f(`${GITHUB_API}/copilot_internal/user`, { headers: githubHeaders(githubToken) });
|
|
255
|
+
if (!r.ok) return null;
|
|
256
|
+
const j = await r.json().catch(() => null);
|
|
257
|
+
const q = j && j.quota_snapshots && j.quota_snapshots.premium_interactions;
|
|
258
|
+
if (!q || typeof q !== 'object') return null;
|
|
259
|
+
const entitlement = Number.isFinite(Number(q.entitlement)) ? Number(q.entitlement) : null;
|
|
260
|
+
const remaining = Number.isFinite(Number(q.remaining)) ? Number(q.remaining) : null;
|
|
261
|
+
return {
|
|
262
|
+
used: entitlement != null && remaining != null ? Math.max(0, entitlement - remaining) : null,
|
|
263
|
+
entitlement,
|
|
264
|
+
remaining,
|
|
265
|
+
percentRemaining: Number.isFinite(Number(q.percent_remaining)) ? Number(q.percent_remaining) : null,
|
|
266
|
+
unlimited: q.unlimited === true,
|
|
267
|
+
resetDate: typeof j.quota_reset_date === 'string' ? j.quota_reset_date : null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// src/core/bridge/providers/endpoint.mjs
|
|
2
|
+
// Discovery for OpenAI-compatible endpoints (model-bridge-design.md §8.4, the Copilot import's
|
|
3
|
+
// shape for a server you run yourself): ask a base URL what it serves, and turn a pick into a
|
|
4
|
+
// catalog entry. Pure over an injected `fetch`; nothing here reads settings or writes anything.
|
|
5
|
+
//
|
|
6
|
+
// `GET /v1/models` is the only call every server answers, and it carries ids and nothing else —
|
|
7
|
+
// not the context window, not whether the model can call tools, both of which decide whether a
|
|
8
|
+
// Worca pipeline can use it at all. So each server is probed on its own endpoint first and the
|
|
9
|
+
// OpenAI list is the fallback:
|
|
10
|
+
//
|
|
11
|
+
// llama.cpp GET {root}/props default_generation_settings.n_ctx is the window ONE request
|
|
12
|
+
// gets (-c split across --parallel slots), chat_template_caps
|
|
13
|
+
// says whether the template takes tools, modalities vision.
|
|
14
|
+
// GET {base}/models data[].meta.n_ctx / n_ctx_train / n_params.
|
|
15
|
+
// Ollama GET {root}/api/tags details.context_length is what the model was TRAINED for;
|
|
16
|
+
// capabilities carries tools / vision / thinking.
|
|
17
|
+
// GET {root}/api/ps a loaded model's real context_length, when one is loaded.
|
|
18
|
+
// LM Studio GET {root}/api/v0/models type (llm | vlm | embeddings), state, max_context_length,
|
|
19
|
+
// loaded_context_length.
|
|
20
|
+
// vLLM / other GET {base}/models ids, plus max_model_len where the server sets it.
|
|
21
|
+
//
|
|
22
|
+
// The window a model is SERVED with and the one it was TRAINED for are different numbers, and only
|
|
23
|
+
// the served one may become a prompt limit: Ollama serves 4096 by default however large the model
|
|
24
|
+
// is, and llama.cpp divides -c across its slots. When the served value is unknown the entry is
|
|
25
|
+
// built WITHOUT a prompt limit and the caller's warning says so — a wrong window is worse than
|
|
26
|
+
// none, because the CLI would compact against a number the endpoint never had.
|
|
27
|
+
|
|
28
|
+
const SERVERS = Object.freeze({
|
|
29
|
+
'llama.cpp': 'llama.cpp', ollama: 'Ollama', lmstudio: 'LM Studio', vllm: 'vLLM', 'openai-compatible': 'OpenAI-compatible',
|
|
30
|
+
});
|
|
31
|
+
/** The catalog-id prefix per server, so two endpoints' models never collide. */
|
|
32
|
+
const ID_PREFIX = Object.freeze({ 'llama.cpp': 'llama', ollama: 'ollama', lmstudio: 'lmstudio', vllm: 'vllm', 'openai-compatible': 'local' });
|
|
33
|
+
/** Below this a pipeline thrashes the CLI's auto-compact (docs/models.md Troubleshooting). */
|
|
34
|
+
export const MIN_PIPELINE_WINDOW = 65536;
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
/** The server root of a base URL: the same URL without its trailing `/v1` (`/props` lives there). */
|
|
38
|
+
export function endpointRoot(baseUrl) {
|
|
39
|
+
return String(baseUrl || '').trim().replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const num = (v) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
|
|
43
|
+
const has = (list, name) => Array.isArray(list) && list.includes(name);
|
|
44
|
+
/** `qwen3-coder:30b`, `/models/Qwen3.6-35B.gguf` → a catalog-id stem. */
|
|
45
|
+
export function slugModelId(id) {
|
|
46
|
+
const base = String(id || '').split(/[\\/]/).pop().replace(/\.gguf$/i, '');
|
|
47
|
+
return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'model';
|
|
48
|
+
}
|
|
49
|
+
const GB = (n) => (num(n) ? `${(n / 1e9).toFixed(1)} GB` : null);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One JSON GET (or POST) that never throws: `null` on any failure, so a probe for a server that is
|
|
53
|
+
* not there costs one request and no error handling at the call sites.
|
|
54
|
+
*/
|
|
55
|
+
async function json(f, url, { timeoutMs, headers = {}, body = null } = {}) {
|
|
56
|
+
try {
|
|
57
|
+
const r = await f(url, {
|
|
58
|
+
...(body ? { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json', ...headers } } : { headers }),
|
|
59
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
60
|
+
});
|
|
61
|
+
if (!r.ok) return null;
|
|
62
|
+
return await r.json();
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function llamaModels(list, props) {
|
|
67
|
+
const caps = (props && props.chat_template_caps) || {};
|
|
68
|
+
const modal = (props && props.modalities) || {};
|
|
69
|
+
const rows = Array.isArray(list && list.data) ? list.data : [];
|
|
70
|
+
const slots = num(props && props.total_slots);
|
|
71
|
+
return rows.map((m) => {
|
|
72
|
+
const meta = m.meta || {};
|
|
73
|
+
return {
|
|
74
|
+
id: String(m.id ?? props?.model_alias ?? ''),
|
|
75
|
+
name: String(m.id ?? props?.model_alias ?? ''),
|
|
76
|
+
kind: 'llm',
|
|
77
|
+
servedContext: num(meta.n_ctx),
|
|
78
|
+
trainedContext: num(meta.n_ctx_train),
|
|
79
|
+
toolCalls: caps.supports_tools === true || caps.supports_tool_calls === true,
|
|
80
|
+
vision: modal.vision === true,
|
|
81
|
+
reasoning: caps.supports_reasoning_effort === true,
|
|
82
|
+
loaded: true,
|
|
83
|
+
detail: [meta.ftype, meta.n_params ? `${(meta.n_params / 1e9).toFixed(0)}B` : null, slots > 1 ? `${slots} slots` : null].filter(Boolean).join(' · ') || null,
|
|
84
|
+
};
|
|
85
|
+
}).filter((m) => m.id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ollamaModels(tags, ps) {
|
|
89
|
+
const loaded = new Map((Array.isArray(ps && ps.models) ? ps.models : []).map((m) => [String(m.name ?? m.model), num(m.context_length)]));
|
|
90
|
+
return (Array.isArray(tags && tags.models) ? tags.models : []).map((m) => {
|
|
91
|
+
const id = String(m.name ?? m.model ?? '');
|
|
92
|
+
const d = m.details || {};
|
|
93
|
+
const caps = m.capabilities;
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
name: id,
|
|
97
|
+
kind: has(caps, 'embedding') ? 'embedding' : 'llm',
|
|
98
|
+
servedContext: loaded.get(id) ?? null,
|
|
99
|
+
trainedContext: num(d.context_length),
|
|
100
|
+
toolCalls: has(caps, 'tools'),
|
|
101
|
+
vision: has(caps, 'vision'),
|
|
102
|
+
reasoning: has(caps, 'thinking'),
|
|
103
|
+
loaded: loaded.has(id),
|
|
104
|
+
detail: [d.parameter_size, d.quantization_level, GB(m.size)].filter(Boolean).join(' · ') || null,
|
|
105
|
+
};
|
|
106
|
+
}).filter((m) => m.id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function lmStudioModels(list) {
|
|
110
|
+
return (Array.isArray(list && list.data) ? list.data : []).map((m) => {
|
|
111
|
+
const type = String(m.type || '');
|
|
112
|
+
return {
|
|
113
|
+
id: String(m.id ?? ''),
|
|
114
|
+
name: String(m.id ?? ''),
|
|
115
|
+
kind: type === 'embeddings' ? 'embedding' : 'llm',
|
|
116
|
+
servedContext: num(m.loaded_context_length),
|
|
117
|
+
trainedContext: num(m.max_context_length),
|
|
118
|
+
// LM Studio does not report tool support; its LLMs generally take tools, and a model that
|
|
119
|
+
// cannot is refused at the first call rather than silently mis-flagged here.
|
|
120
|
+
toolCalls: type === 'llm' || type === 'vlm',
|
|
121
|
+
vision: type === 'vlm',
|
|
122
|
+
reasoning: false,
|
|
123
|
+
loaded: m.state === 'loaded',
|
|
124
|
+
detail: [m.quantization, m.arch, m.publisher].filter(Boolean).join(' · ') || null,
|
|
125
|
+
};
|
|
126
|
+
}).filter((m) => m.id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function openAiModels(list) {
|
|
130
|
+
return (Array.isArray(list && list.data) ? list.data : []).map((m) => ({
|
|
131
|
+
id: String(m.id ?? ''),
|
|
132
|
+
name: String(m.id ?? ''),
|
|
133
|
+
kind: 'llm',
|
|
134
|
+
// vLLM reports the served length here; nothing else on the generic surface does.
|
|
135
|
+
servedContext: num(m.max_model_len),
|
|
136
|
+
trainedContext: null,
|
|
137
|
+
toolCalls: null, // unknown, not "no"
|
|
138
|
+
vision: null,
|
|
139
|
+
reasoning: null,
|
|
140
|
+
loaded: null,
|
|
141
|
+
detail: typeof m.owned_by === 'string' && m.owned_by ? m.owned_by : null,
|
|
142
|
+
})).filter((m) => m.id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What the caller must know before pinning a prompt limit on what this server reports. */
|
|
146
|
+
function warningsFor(server, models, props) {
|
|
147
|
+
const w = [];
|
|
148
|
+
if (server === 'ollama') {
|
|
149
|
+
w.push('Ollama serves a 4096-token window by default, whatever the model was trained for: start it with OLLAMA_CONTEXT_LENGTH=65536 (or set num_ctx on the model) and load the model, then re-import — otherwise set the prompt limit by hand to what it really serves.');
|
|
150
|
+
}
|
|
151
|
+
if (server === 'llama.cpp') {
|
|
152
|
+
const slots = num(props && props.total_slots);
|
|
153
|
+
// Not always a split: with a unified KV cache each slot sees the whole -c, and llama reports the
|
|
154
|
+
// per-request window either way. Say what the number IS rather than claiming arithmetic.
|
|
155
|
+
if (slots > 1) w.push(`llama-server is serving ${slots} slots in parallel; the window shown is what ONE request gets, which may be less than the -c you passed.`);
|
|
156
|
+
}
|
|
157
|
+
if (server === 'lmstudio' && models.some((m) => m.kind !== 'embedding' && !m.servedContext)) {
|
|
158
|
+
w.push('LM Studio reports a model\'s real window only while it is loaded; for the others the number shown is what the model supports, and the prompt limit is left unset.');
|
|
159
|
+
}
|
|
160
|
+
if (server === 'openai-compatible' && models.every((m) => !m.servedContext)) {
|
|
161
|
+
w.push('This endpoint does not report context windows — set each model\'s prompt limit by hand after importing.');
|
|
162
|
+
}
|
|
163
|
+
const small = models.filter((m) => m.kind !== 'embedding' && m.servedContext && m.servedContext < MIN_PIPELINE_WINDOW);
|
|
164
|
+
if (small.length) w.push(`${small.length === 1 ? 'One model serves' : `${small.length} models serve`} less than ${MIN_PIPELINE_WINDOW} tokens — a pipeline thrashes the CLI's auto-compact below that.`);
|
|
165
|
+
return w;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Ask an OpenAI-compatible endpoint what it serves.
|
|
170
|
+
* @param {string} baseUrl the endpoint's OpenAI base URL (…/v1)
|
|
171
|
+
* @param {{apiKey?:string, fetch?:typeof fetch, timeoutMs?:number}} [opts]
|
|
172
|
+
* @returns {Promise<{server:string, serverLabel:string, baseUrl:string, models:Array<object>, warnings:string[]}>}
|
|
173
|
+
* @throws {Error} when nothing answers at all (the caller shows it as the connection failure it is)
|
|
174
|
+
*/
|
|
175
|
+
export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
176
|
+
const base = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
177
|
+
if (!base) throw new Error('baseUrl is required');
|
|
178
|
+
const root = endpointRoot(base);
|
|
179
|
+
const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
180
|
+
const opt = { timeoutMs, headers };
|
|
181
|
+
let server = 'openai-compatible';
|
|
182
|
+
let models = [];
|
|
183
|
+
let props = null;
|
|
184
|
+
|
|
185
|
+
const props0 = await json(f, `${root}/props`, opt);
|
|
186
|
+
if (props0 && (props0.default_generation_settings || props0.chat_template_caps)) {
|
|
187
|
+
server = 'llama.cpp';
|
|
188
|
+
props = props0;
|
|
189
|
+
models = llamaModels(await json(f, `${base}/models`, opt), props0);
|
|
190
|
+
if (!models.length) {
|
|
191
|
+
const n = num(props0.default_generation_settings && props0.default_generation_settings.n_ctx);
|
|
192
|
+
const id = String(props0.model_alias || props0.model_path || '').split(/[\\/]/).pop();
|
|
193
|
+
if (id) models = [{ id, name: id, kind: 'llm', servedContext: n, trainedContext: null, toolCalls: props0.chat_template_caps?.supports_tools === true, vision: props0.modalities?.vision === true, reasoning: false, loaded: true, detail: null }];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!models.length) {
|
|
197
|
+
const tags = await json(f, `${root}/api/tags`, opt);
|
|
198
|
+
if (tags && Array.isArray(tags.models)) {
|
|
199
|
+
server = 'ollama';
|
|
200
|
+
models = ollamaModels(tags, await json(f, `${root}/api/ps`, opt));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!models.length) {
|
|
204
|
+
const lms = await json(f, `${root}/api/v0/models`, opt);
|
|
205
|
+
if (lms && Array.isArray(lms.data) && lms.data.some((m) => m && m.type)) {
|
|
206
|
+
server = 'lmstudio';
|
|
207
|
+
models = lmStudioModels(lms);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!models.length) {
|
|
211
|
+
const list = await json(f, `${base}/models`, opt);
|
|
212
|
+
if (!list) throw new Error(`no OpenAI-compatible model list at ${base}/models — is the server running, and is the base URL right?`);
|
|
213
|
+
models = openAiModels(list);
|
|
214
|
+
if (models.some((m) => m.servedContext)) server = 'vllm';
|
|
215
|
+
}
|
|
216
|
+
models.sort((a, b) => (Number(b.kind !== 'embedding') - Number(a.kind !== 'embedding')) || (Number(b.loaded === true) - Number(a.loaded === true)) || a.id.localeCompare(b.id));
|
|
217
|
+
return { server, serverLabel: SERVERS[server], baseUrl: base, models, warnings: warningsFor(server, models, props) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A discovered model as a catalog entry (§8.4's Copilot shape, for a server you run).
|
|
222
|
+
* `baseUrl` rides the ENTRY when it differs from the provider's, so one catalog can hold an Ollama
|
|
223
|
+
* and a llama.cpp model at once. A prompt limit is pinned only from a window the server really
|
|
224
|
+
* serves; `maxOutputTokens` is never guessed — no local server reports one.
|
|
225
|
+
* @param {object} m a row from listEndpointModels
|
|
226
|
+
* @param {{server:string, baseUrl:string, providerBaseUrl?:string}} ctx
|
|
227
|
+
*/
|
|
228
|
+
export function catalogEntryForEndpointModel(m, { server = 'openai-compatible', baseUrl, providerBaseUrl = '' } = {}) {
|
|
229
|
+
const capabilities = {
|
|
230
|
+
...(m.toolCalls === true || m.toolCalls === false ? { toolCalls: m.toolCalls } : {}),
|
|
231
|
+
...(m.vision === true ? { vision: true } : {}),
|
|
232
|
+
...(m.reasoning === true ? { reasoning: true } : {}),
|
|
233
|
+
...(num(m.servedContext) ? { maxPromptTokens: num(m.servedContext) } : {}),
|
|
234
|
+
};
|
|
235
|
+
const own = String(baseUrl || '').replace(/\/+$/, '');
|
|
236
|
+
const provider = String(providerBaseUrl || '').replace(/\/+$/, '');
|
|
237
|
+
return {
|
|
238
|
+
id: `${ID_PREFIX[server] || 'local'}-${slugModelId(m.id)}`,
|
|
239
|
+
label: `${m.name || m.id} (${SERVERS[server] || 'local'})`,
|
|
240
|
+
...(m.reasoning === true ? {} : { efforts: ['medium'] }),
|
|
241
|
+
upstream: {
|
|
242
|
+
provider: 'openai', api: 'openai-chat', model: m.id,
|
|
243
|
+
...(own && own !== provider ? { baseUrl: own } : {}),
|
|
244
|
+
...(Object.keys(capabilities).length ? { capabilities } : {}),
|
|
245
|
+
},
|
|
246
|
+
cost: { free: true }, // a model on your own machine bills nothing; never "cost not verified"
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether a discovered model can carry a pipeline at all (the sheet greys the rest). */
|
|
251
|
+
export function importableModel(m) {
|
|
252
|
+
if (!m || m.kind === 'embedding') return { ok: false, why: 'an embedding model — not a chat model' };
|
|
253
|
+
if (m.toolCalls === false) return { ok: false, why: 'no tool calls — a pipeline agent cannot run without them' };
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { SERVERS as ENDPOINT_SERVERS };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/core/bridge/registry.mjs
|
|
2
|
+
// Which catalog entries are bridged, and with what (model-bridge-design.md
|
|
3
|
+
// §4.2/§6). Import contract: settings.mjs, plugin-models.mjs, the policy
|
|
4
|
+
// cache and the zero-import model-env.mjs leaf only — config.mjs imports the
|
|
5
|
+
// bridge (for resolveModelEnv), so nothing under src/core/bridge/ may import
|
|
6
|
+
// config.mjs.
|
|
7
|
+
|
|
8
|
+
import { listGlobalModels, providerConfig, providerSecretSet, resolveProviderSecret, copilotTermsAcknowledged } from '../settings.mjs';
|
|
9
|
+
import { listPluginModels } from '../plugin-models.mjs';
|
|
10
|
+
import { policyCatalogModels } from '../policy/cache.mjs';
|
|
11
|
+
import { isLocalBaseUrl } from '../model-env.mjs';
|
|
12
|
+
|
|
13
|
+
/** An OpenAI-compatible endpoint on this machine / a private network needs no key. */
|
|
14
|
+
export function keyOptional(provider, baseUrl) {
|
|
15
|
+
return provider === 'openai' && isLocalBaseUrl(baseUrl);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The bridged catalog entry for `id` (user global → plugin → team policy), or
|
|
20
|
+
* null when the id is unknown or not bridged. Shape:
|
|
21
|
+
* `{id, label, upstream, cost?, source: 'global'|'plugin'|'policy', plugin?}`.
|
|
22
|
+
* Synchronous; never throws.
|
|
23
|
+
*/
|
|
24
|
+
export function findBridgedEntry(id) {
|
|
25
|
+
const key = typeof id === 'string' ? id.trim().toLowerCase() : '';
|
|
26
|
+
if (!key) return null;
|
|
27
|
+
const g = listGlobalModels().find((m) => m.id.toLowerCase() === key);
|
|
28
|
+
if (g) return g.upstream ? { id: g.id, label: g.label, upstream: g.upstream, cost: g.cost, source: 'global' } : null;
|
|
29
|
+
const p = listPluginModels().find((m) => m.id.toLowerCase() === key);
|
|
30
|
+
if (p) return p.upstream ? { id: p.id, label: p.label, upstream: p.upstream, cost: p.cost, source: 'plugin', plugin: p.plugin } : null;
|
|
31
|
+
let t = null;
|
|
32
|
+
try { t = policyCatalogModels().find((m) => m.id.toLowerCase() === key); } catch { t = null; }
|
|
33
|
+
if (t && t.upstream) return { id: t.id, label: t.label, upstream: t.upstream, source: 'policy' };
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the provider behind `upstream` can be used right now, and if not,
|
|
39
|
+
* why — the "needs sign-in" state the UI and the spawn fail-fast share (§8.5).
|
|
40
|
+
* @returns {{ok:true}|{ok:false, reason:'not_signed_in'|'terms'|'no_key', message:string}}
|
|
41
|
+
*/
|
|
42
|
+
export function providerReadiness(upstream) {
|
|
43
|
+
if (!upstream) return { ok: true };
|
|
44
|
+
const p = upstream.provider;
|
|
45
|
+
if (p === 'copilot') {
|
|
46
|
+
if (!copilotTermsAcknowledged()) {
|
|
47
|
+
return { ok: false, reason: 'terms', message: 'provider copilot: terms not acknowledged — open Settings › Providers' };
|
|
48
|
+
}
|
|
49
|
+
const cfg = providerConfig('copilot');
|
|
50
|
+
if (!resolveProviderSecret(cfg.githubToken)) {
|
|
51
|
+
return { ok: false, reason: 'not_signed_in', message: 'provider copilot: not signed in — run `worca models login copilot` or open Settings › Providers' };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
// openai / anthropic: a per-entry key wins, else the provider's key.
|
|
56
|
+
const cfg = providerConfig(p);
|
|
57
|
+
const key = resolveProviderSecret(upstream.apiKey) || resolveProviderSecret(cfg.apiKey);
|
|
58
|
+
const has = providerSecretSet(p) || !!upstream.apiKey;
|
|
59
|
+
// No key configured anywhere + a local endpoint = keyless. A configured key
|
|
60
|
+
// whose ${VAR} is unset still blocks: the user meant to send one.
|
|
61
|
+
if (!key && (has || !keyOptional(p, upstream.baseUrl || cfg.baseUrl))) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false, reason: 'no_key',
|
|
64
|
+
message: has
|
|
65
|
+
? `provider ${p}: the API key's \${VAR} is not set in worca's environment`
|
|
66
|
+
: `provider ${p}: no API key — open Settings › Providers`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Effective upstream settings for a request: base URL, key, headers, concurrency. */
|
|
73
|
+
export function upstreamSettings(upstream) {
|
|
74
|
+
const cfg = providerConfig(upstream.provider);
|
|
75
|
+
const p = upstream.provider;
|
|
76
|
+
return {
|
|
77
|
+
provider: p,
|
|
78
|
+
api: upstream.api,
|
|
79
|
+
model: upstream.model,
|
|
80
|
+
baseUrl: upstream.baseUrl || cfg.baseUrl || null,
|
|
81
|
+
apiKey: p === 'copilot' ? null : (resolveProviderSecret(upstream.apiKey) || resolveProviderSecret(cfg.apiKey) || ''),
|
|
82
|
+
githubToken: p === 'copilot' ? resolveProviderSecret(cfg.githubToken) : null,
|
|
83
|
+
accountType: p === 'copilot' ? cfg.accountType : null,
|
|
84
|
+
headers: upstream.headers || {},
|
|
85
|
+
capabilities: upstream.capabilities || {},
|
|
86
|
+
maxConcurrent: cfg.maxConcurrent,
|
|
87
|
+
};
|
|
88
|
+
}
|