@worca/app 1.4.0 → 1.5.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- package/ui/server.mjs +454 -104
package/src/core/ask/tools.mjs
CHANGED
|
@@ -319,6 +319,20 @@ export function createAskTools(deps) {
|
|
|
319
319
|
catch { return null; }
|
|
320
320
|
};
|
|
321
321
|
|
|
322
|
+
// The person signed in to this chat on a shared sign-in (identity.mjs isSharedIdentity), else null.
|
|
323
|
+
const viewer = () => (typeof deps.viewer === 'string' && deps.viewer ? deps.viewer : null);
|
|
324
|
+
// A startedBy filter: lowercased; "me" resolves to the viewer, and is refused without a shared sign-in
|
|
325
|
+
// (on a local or one-person worca runs are not told apart by person).
|
|
326
|
+
const personFilter = (v, tool) => {
|
|
327
|
+
const raw = str(v);
|
|
328
|
+
if (!raw) return '';
|
|
329
|
+
if (raw.toLowerCase() === 'me') {
|
|
330
|
+
if (!viewer()) throw new AskToolError(`${tool}: "me" needs a per-person sign-in; this worca does not tell people apart — name the person, or leave startedBy out`);
|
|
331
|
+
return viewer().toLowerCase();
|
|
332
|
+
}
|
|
333
|
+
return raw.replace(/ \(this machine\)$/i, '').toLowerCase();
|
|
334
|
+
};
|
|
335
|
+
|
|
322
336
|
const defs = [
|
|
323
337
|
{ name: 'list_projects',
|
|
324
338
|
description: 'List the registered projects (key, name, path) and workspaces (id, name, member project keys). Use the key / id in the other tools.',
|
|
@@ -327,11 +341,15 @@ export function createAskTools(deps) {
|
|
|
327
341
|
description: 'List the saved workflows with their ordered step groups (parallel agent nodes share a group) and feedback loops. Pick one by name, domain and steps.',
|
|
328
342
|
inputSchema: SCHEMA.obj({}) },
|
|
329
343
|
{ name: 'list_runs',
|
|
330
|
-
description: 'Find past runs, newest first. Optional filters: projectKey OR workspaceId, status (e.g. done, running, error, stopped), query (title substring). limit defaults to 20, max 100.',
|
|
344
|
+
description: 'Find past runs, newest first. Optional filters: projectKey OR workspaceId, status (e.g. done, running, error, stopped), query (title substring), startedBy (the person who started the run, exactly as list_people names them, case-insensitive; "me" = the person signed in to this chat, only on a shared sign-in). Each row carries startedBy (a person, "local" for this machine, null before attribution). limit defaults to 20, max 100.',
|
|
331
345
|
inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'), workspaceId: SCHEMA.s('workspace id from list_projects'),
|
|
332
|
-
status: SCHEMA.s('run status to match'), limit: SCHEMA.i('max results (1-100)', 1, L.listRunsMaxLimit), query: SCHEMA.s('case-insensitive title substring')
|
|
346
|
+
status: SCHEMA.s('run status to match'), limit: SCHEMA.i('max results (1-100)', 1, L.listRunsMaxLimit), query: SCHEMA.s('case-insensitive title substring'),
|
|
347
|
+
startedBy: SCHEMA.s('who started the run (a name from list_people, or "me")') }) },
|
|
348
|
+
{ name: 'list_people',
|
|
349
|
+
description: 'Who has started runs on this worca: one row per person (the name recorded when they started a run — a verified sign-in email, a trusted-header name, or the operator\'s declared name; "local" = runs started on this machine with no identity) with runs, lastRunAt and totalCostUsd, most active first. Optional scope: projectKey OR workspaceId. Runs from before attribution existed have no person and are not counted. Read-only; this machine\'s runs only (team-wide people are in get_team_metrics actor breakdowns).',
|
|
350
|
+
inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'), workspaceId: SCHEMA.s('workspace id from list_projects') }) },
|
|
333
351
|
{ name: 'get_run',
|
|
334
|
-
description: 'Read one run: its metadata
|
|
352
|
+
description: 'Read one run: its metadata, the user\'s original prompt, startedBy (the person who started it), `scheduled` when a schedule started it, and `actions` — the people who acted on it (paused, resumed, stopped, answered its questions, continued past a cost cap, opened its PR, archived it) with when. Give projectKey or workspaceId when known; without them the user-pinned scope (when the chat has one) is tried first, then the id is searched everywhere.',
|
|
335
353
|
inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id (8 hex)'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace') }, ['id']) },
|
|
336
354
|
{ name: 'get_run_diff',
|
|
337
355
|
description: 'Read the unified diff of a run, paged by byte offset (use nextOffset until truncated is false). Optional path = one file only. files[] lists every file with added/removed counts; credential files are omitted.',
|
|
@@ -575,7 +593,7 @@ export function createAskTools(deps) {
|
|
|
575
593
|
// tool-list pin stays byte-identical. Every change is a card the user applies.
|
|
576
594
|
...(deps.models ? [
|
|
577
595
|
{ name: 'list_models',
|
|
578
|
-
description: 'The model catalog every picker, workflow node and Ask chat draws from: id, label, source (built-in | user | plugin | team policy), editable (a user entry — the only kind propose_model_change edits or removes), efforts, connection ("default" = the claude CLI\'s own login; "env" = the entry\'s ANTHROPIC_BASE_URL/… env points the CLI at an endpoint that speaks the Messages API; "provider" = worca\'s bridge forwards to a provider), and for a bridged model its provider, upstreamApi (anthropic passes through; openai-chat
|
|
596
|
+
description: 'The model catalog every picker, workflow node and Ask chat draws from: id, label, source (built-in | user | plugin | team policy), editable (a user entry — the only kind propose_model_change edits or removes), efforts, connection ("default" = the claude CLI\'s own login; "env" = the entry\'s ANTHROPIC_BASE_URL/… env points the CLI at an endpoint that speaks the Messages API; "provider" = worca\'s bridge forwards to a provider), and for a bridged model its provider, upstreamApi (anthropic passes through; openai-chat and openai-responses are translated), upstreamModel, capabilities (maxPromptTokens / maxOutputTokens / reasoningEfforts …) and ready / notReady. A user entry adds its own config under entry: env (credential values masked; ${VAR} references readable), upstream (baseUrl, apiKey masked or ${VAR}, headers, capabilities) and cost. Read-only.',
|
|
579
597
|
inputSchema: SCHEMA.obj({}) },
|
|
580
598
|
{ name: 'get_providers',
|
|
581
599
|
description: 'The providers that bridged models share (Settings › Providers): copilot (connected, login, accountType, termsCurrent — the notice acknowledgement, maxConcurrent), openai and anthropic (baseUrl, keySet, keySource "env" | "stored", keyRef when it is a ${VAR} reference, keyOptional — a local OpenAI-compatible endpoint needs no key, configured — the key resolves now, maxConcurrent). Never a key. Read-only.',
|
|
@@ -584,13 +602,13 @@ export function createAskTools(deps) {
|
|
|
584
602
|
description: 'Check one provider now: openai / anthropic GET the endpoint\'s models list with the configured key (reachability + auth, and how many models it lists); copilot exchanges the sign-in for a Copilot token. Returns {ok:true, models?} or {ok:false, message}. The endpoint is contacted.',
|
|
585
603
|
inputSchema: SCHEMA.obj({ provider: SCHEMA.s('copilot | openai | anthropic') }, ['provider']) },
|
|
586
604
|
{ name: 'list_copilot_models',
|
|
587
|
-
description: 'The chat models GitHub Copilot offers the signed-in account, for import: id (pass it to propose_model_change kind "import_copilot"), name, vendor, context and output limits, whether it is enabled in the account\'s Copilot settings, and inCatalog / catalogId (imports land as copilot-<id>). Fails when Copilot is not signed in. GitHub is contacted.',
|
|
605
|
+
description: 'The chat models GitHub Copilot offers the signed-in account, for import: id (pass it to propose_model_change kind "import_copilot"), name, vendor, context and output limits, whether it is enabled in the account\'s Copilot settings, and inCatalog / catalogId (imports land as copilot-<id>), and api — the wire protocol an import uses (anthropic, openai-chat or openai-responses). Fails when Copilot is not signed in. GitHub is contacted.',
|
|
588
606
|
inputSchema: SCHEMA.obj({}) },
|
|
589
607
|
{ name: 'list_endpoint_models',
|
|
590
608
|
description: 'Ask an OpenAI-compatible endpoint what it serves — llama.cpp\'s llama-server, Ollama, LM Studio, vLLM or a gateway — so a model can be added without typing ids or limits. baseUrl defaults to the openai provider\'s (get_providers); pass one to browse another server without saving it first. Returns server (llama.cpp | ollama | lmstudio | vllm | openai-compatible), baseUrl, warnings, and per model: id (pass it to propose_model_change kind "import_endpoint"), catalogId, inCatalog, kind (llm | embedding), servedContext — the window ONE request really gets, the only number a prompt limit may come from — trainedContext (what the model supports, often far larger), toolCalls, vision, reasoning, loaded, and importable / blocked. Read the warnings out: Ollama serves 4096 tokens by default whatever the model supports, and llama-server splits its -c across --parallel slots. The endpoint is contacted.',
|
|
591
609
|
inputSchema: SCHEMA.obj({ baseUrl: SCHEMA.s('the endpoint\'s OpenAI base URL, e.g. http://127.0.0.1:11434/v1 (default: the openai provider\'s)') }) },
|
|
592
610
|
{ name: 'propose_model_change',
|
|
593
|
-
description: 'Propose a model catalog or provider change for the user to confirm — it never changes anything itself; the user sees a card with the before → after and applies or declines it. kind: "add_model" (model: {id, label?, efforts?, env?, cost?, upstream?} — a Messages-API endpoint by env: {ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN:"${VAR}", …}; through a provider by upstream: {provider: openai | anthropic | copilot, api: anthropic | openai-chat, model: <the id the endpoint expects>, baseUrl?, apiKey?: "${VAR}", headers?, capabilities?: {maxPromptTokens, maxOutputTokens, …}}; cost: {free:true} or {perMtok:{input, output, …}}), "edit_model" (id + model: the fields to change — env merges per key, null deletes a key; upstream merges into the current block, a null field removes it, capabilities merge per key; upstream:null drops the bridge), "remove_model" (id — workflow nodes that name it fall back to the default model), "provider" (provider + set: {baseUrl?, apiKey?: "${VAR}", maxConcurrent?, accountType?: individual | business | enterprise (copilot)}; null or "" clears a field), "import_copilot" (ids from list_copilot_models), "import_endpoint" (ids from list_endpoint_models, with its baseUrl when you passed one — each becomes a free, keyless entry carrying the window the server reports). Credentials are ${VAR} references to variables in worca\'s environment, never the value: a literal key is refused — the user pastes one in Settings › Providers. The Copilot sign-in and its notice are the user\'s, on the Providers card (Settings › Providers). Returns {ok:true, card} (card.warnings: what will still stop the model working) or {ok:false, errors} to fix and retry. Never claim a change was applied — the card says so when it happens.',
|
|
611
|
+
description: 'Propose a model catalog or provider change for the user to confirm — it never changes anything itself; the user sees a card with the before → after and applies or declines it. kind: "add_model" (model: {id, label?, efforts?, env?, cost?, upstream?} — a Messages-API endpoint by env: {ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN:"${VAR}", …}; through a provider by upstream: {provider: openai | anthropic | copilot, api: anthropic | openai-chat | openai-responses, model: <the id the endpoint expects>, baseUrl?, apiKey?: "${VAR}", headers?, capabilities?: {maxPromptTokens, maxOutputTokens, reasoning, reasoningEfforts?: the effort levels the model lists, …}}; cost: {free:true} or {perMtok:{input, output, …}}), "edit_model" (id + model: the fields to change — env merges per key, null deletes a key; upstream merges into the current block, a null field removes it, capabilities merge per key; upstream:null drops the bridge), "remove_model" (id — workflow nodes that name it fall back to the default model), "provider" (provider + set: {baseUrl?, apiKey?: "${VAR}", maxConcurrent?, accountType?: individual | business | enterprise (copilot)}; null or "" clears a field), "import_copilot" (ids from list_copilot_models), "import_endpoint" (ids from list_endpoint_models, with its baseUrl when you passed one — each becomes a free, keyless entry carrying the window the server reports). Credentials are ${VAR} references to variables in worca\'s environment, never the value: a literal key is refused — the user pastes one in Settings › Providers. The Copilot sign-in and its notice are the user\'s, on the Providers card (Settings › Providers). Returns {ok:true, card} (card.warnings: what will still stop the model working) or {ok:false, errors} to fix and retry. Never claim a change was applied — the card says so when it happens.',
|
|
594
612
|
inputSchema: SCHEMA.obj({ kind: SCHEMA.s('add_model | edit_model | remove_model | provider | import_copilot'),
|
|
595
613
|
id: SCHEMA.s('edit_model / remove_model: the catalog model id'),
|
|
596
614
|
model: { type: 'object', description: 'add_model: the entry; edit_model: the fields to change', additionalProperties: true },
|
|
@@ -600,6 +618,14 @@ export function createAskTools(deps) {
|
|
|
600
618
|
baseUrl: SCHEMA.s('import_endpoint: the endpoint listed, when it is not the provider\'s own'),
|
|
601
619
|
note: SCHEMA.s('one line shown on the card: why this change (≤ 200 chars)') }, ['kind']) },
|
|
602
620
|
] : []),
|
|
621
|
+
...(deps.clones ? [
|
|
622
|
+
{ name: 'propose_clone_project',
|
|
623
|
+
description: 'Propose adding a project by cloning a repository into worca\'s projects folder, for the user to confirm — it never clones anything itself; the user sees a card with the URL, branch, target folder and how GitHub is reached, and applies or declines it. url: an https:// repository URL (https://github.com/owner/repo), no credentials in it. branch: optional (default: the repository\'s default branch). name: optional folder and project name (default: the repository name). Returns {ok:true, card} or {ok:false, errors} — fix the input and call again.',
|
|
624
|
+
inputSchema: SCHEMA.obj({ url: SCHEMA.s('https:// repository URL, e.g. https://github.com/acme/api'),
|
|
625
|
+
branch: SCHEMA.s('branch to check out (default: the repository default)'),
|
|
626
|
+
name: SCHEMA.s('folder and project name (default: the repository name)'),
|
|
627
|
+
note: SCHEMA.s('one line shown on the card: why (≤ 200 chars)') }, ['url']) },
|
|
628
|
+
] : []),
|
|
603
629
|
];
|
|
604
630
|
|
|
605
631
|
const EMPTY_DIFF = () => ({ available: false, files: [], text: '', truncated: false, totalBytes: 0, nextOffset: 0 });
|
|
@@ -673,8 +699,10 @@ export function createAskTools(deps) {
|
|
|
673
699
|
prompt: row.prompt == null ? null : deps.redact(row.prompt), // run prompts are untrusted text (spec §6.3/§6.6)
|
|
674
700
|
totalCostUsd: deps.totalsFor(row).cost,
|
|
675
701
|
archived: !!row.archived_at,
|
|
702
|
+
// The person who started it (identity.mjs; 'local' on a local install, null before attribution).
|
|
703
|
+
startedBy: row.started_by ?? null,
|
|
676
704
|
// Started by a schedule (docs/scheduled-runs.md): only then, so every other run keeps its shape.
|
|
677
|
-
...(row.scheduled_for || row.schedule_id ? {
|
|
705
|
+
...(row.scheduled_for || row.schedule_id ? { scheduled: { scheduledFor: row.scheduled_for ?? null, scheduleId: row.schedule_id ?? null } } : {}),
|
|
678
706
|
};
|
|
679
707
|
}
|
|
680
708
|
|
|
@@ -1093,7 +1121,17 @@ export function createAskTools(deps) {
|
|
|
1093
1121
|
overlap: s.overlap, maxFailures: s.maxFailures, failureStreak: s.failureStreak, ifMissed: s.ifMissed, graceMin: s.graceMin,
|
|
1094
1122
|
runsCount: s.runsCount, lastResult: s.lastResult, request: shapeRequest(s.summary),
|
|
1095
1123
|
...(s.askCardId ? { askCardId: s.askCardId } : {}),
|
|
1124
|
+
...peopleOf(s),
|
|
1096
1125
|
});
|
|
1126
|
+
// Who made / last changed a series or scheduled run (identity.mjs actors); absent when unknown, and
|
|
1127
|
+
// 'local' is not a person, so it is left out too.
|
|
1128
|
+
const peopleOf = (x) => {
|
|
1129
|
+
const person = (v) => (typeof v === 'string' && v && v !== 'local' ? v : null);
|
|
1130
|
+
const out = {};
|
|
1131
|
+
if (person(x.createdBy)) out.createdBy = person(x.createdBy);
|
|
1132
|
+
if (person(x.updatedBy)) out.updatedBy = person(x.updatedBy);
|
|
1133
|
+
return out;
|
|
1134
|
+
};
|
|
1097
1135
|
const shapeTicket = (t) => {
|
|
1098
1136
|
// Run chains (spec D11): a waiting after-ticket's run_at is the year-9999 sentinel — never a time to show.
|
|
1099
1137
|
// The model sees what it waits for instead; `after.id` is a run id (get_run) or a scheduled run id (get_schedule).
|
|
@@ -1104,6 +1142,7 @@ export function createAskTools(deps) {
|
|
|
1104
1142
|
after: t.after ? { kind: t.after.kind, id: t.after.id, policy: t.after.policy } : null, sourceFromPrevious: !!t.sourceFromPrevious,
|
|
1105
1143
|
failReason: t.failReason ? deps.redact(t.failReason) : null, pipelineId: t.pipelineId, attempts: t.attempts,
|
|
1106
1144
|
ifMissed: t.ifMissed, graceMin: t.graceMin, heldByTerminal: !!t.ownerPid, request: shapeRequest(t.summary),
|
|
1145
|
+
...peopleOf(t),
|
|
1107
1146
|
};
|
|
1108
1147
|
};
|
|
1109
1148
|
const shapeNotice = (n) => ({
|
|
@@ -1252,6 +1291,7 @@ export function createAskTools(deps) {
|
|
|
1252
1291
|
const status = str(input.status).toLowerCase();
|
|
1253
1292
|
const query = str(input.query).toLowerCase();
|
|
1254
1293
|
const limit = clampInt(input.limit, 1, L.listRunsMaxLimit, L.listRunsDefaultLimit);
|
|
1294
|
+
const startedBy = personFilter(input.startedBy, 'list_runs');
|
|
1255
1295
|
// Unkeyed: the newest runsScanLimit rows are enough. Keyed: scan everything — a
|
|
1256
1296
|
// project's runs may all be older than the 200 globally newest (lite = one SQL
|
|
1257
1297
|
// + one readdir per store key, no git).
|
|
@@ -1261,6 +1301,7 @@ export function createAskTools(deps) {
|
|
|
1261
1301
|
if (wantKey && e.projectKey !== wantKey) continue;
|
|
1262
1302
|
if (status && String(e.status ?? '').toLowerCase() !== status) continue;
|
|
1263
1303
|
if (query && !String(e.title ?? '').toLowerCase().includes(query)) continue;
|
|
1304
|
+
if (startedBy && String(e.startedBy ?? '').toLowerCase() !== startedBy) continue;
|
|
1264
1305
|
const isWs = e.target === 'workspace' || String(e.projectKey).startsWith('workspaces/');
|
|
1265
1306
|
out.push({
|
|
1266
1307
|
id: e.id, title: deps.redact(e.title ?? e.id), target: isWs ? 'workspace' : 'project',
|
|
@@ -1271,6 +1312,7 @@ export function createAskTools(deps) {
|
|
|
1271
1312
|
updatedAt: e.mtime ? new Date(e.mtime).toISOString() : null,
|
|
1272
1313
|
branch: e.branch ?? null, sourceBranch: e.sourceBranch ?? null, guardrailsId: e.guardrailsId ?? null,
|
|
1273
1314
|
totalCostUsd: e.totalCostUsd ?? null,
|
|
1315
|
+
startedBy: e.startedBy ?? null,
|
|
1274
1316
|
});
|
|
1275
1317
|
if (out.length >= limit) break;
|
|
1276
1318
|
}
|
|
@@ -1283,7 +1325,22 @@ export function createAskTools(deps) {
|
|
|
1283
1325
|
// by the injected dep; a row-only bundle answers null and the shape stays byte-identical).
|
|
1284
1326
|
const memory = typeof deps.readRunMemory === 'function' ? await deps.readRunMemory(row) : null;
|
|
1285
1327
|
const policy = runPolicy(row);
|
|
1286
|
-
|
|
1328
|
+
// Who acted on the run (pipeline_events.actor): only when anyone did, so other runs keep their shape.
|
|
1329
|
+
const actions = typeof deps.readRunActions === 'function' ? await deps.readRunActions(row) : [];
|
|
1330
|
+
return { ...run, hasDiff: !run.archived && await deps.hasDiffPatch(row), ...(memory ? { memory } : {}), ...(policy ? { policy } : {}),
|
|
1331
|
+
...(actions.length ? { actions: actions.map((a) => ({ at: a.at, by: a.by, what: deps.redact(a.what) })) } : {}) };
|
|
1332
|
+
},
|
|
1333
|
+
async list_people(input) {
|
|
1334
|
+
const projectKey = str(input.projectKey);
|
|
1335
|
+
const workspaceId = str(input.workspaceId);
|
|
1336
|
+
if (projectKey && workspaceId) throw new AskToolError('list_people: give projectKey OR workspaceId, not both');
|
|
1337
|
+
if (typeof deps.listPeople !== 'function') return [];
|
|
1338
|
+
const rows = await deps.listPeople(workspaceId ? { workspaceKey: workspaceId } : projectKey ? { projectKey } : {});
|
|
1339
|
+
return rows.map((r) => ({
|
|
1340
|
+
name: r.name === 'local' ? 'local (this machine)' : r.name,
|
|
1341
|
+
...(viewer() && r.name.toLowerCase() === viewer().toLowerCase() ? { you: true } : {}),
|
|
1342
|
+
runs: r.runs, lastRunAt: r.lastRunAt ?? null, totalCostUsd: Number(r.totalCostUsd || 0),
|
|
1343
|
+
}));
|
|
1287
1344
|
},
|
|
1288
1345
|
// Read-only by contract: the parent process (ui/server.mjs askTrackRun, via the turn's onTrackRun hook) does the
|
|
1289
1346
|
// linking and the following. A live run id lives only in the server's runs Map, so the child passes it through.
|
|
@@ -1881,6 +1938,10 @@ export function createAskTools(deps) {
|
|
|
1881
1938
|
catch (err) { throw new AskToolError(`list_copilot_models: ${err && err.message ? err.message : err}`); }
|
|
1882
1939
|
},
|
|
1883
1940
|
async propose_model_change(input) { return modelsOf('propose_model_change').validateChange(input); },
|
|
1941
|
+
async propose_clone_project(input) {
|
|
1942
|
+
if (!deps.clones) throw new AskToolError('propose_clone_project: cloning is unavailable');
|
|
1943
|
+
return deps.clones.validateChange(input);
|
|
1944
|
+
},
|
|
1884
1945
|
async save_script(input) {
|
|
1885
1946
|
const s = scriptWriterOf('save_script');
|
|
1886
1947
|
// The model's OWN text, on its way to disk: never redacted here (a redaction marker
|
package/src/core/ask/turn.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { validateProposal } from './proposal.mjs';
|
|
|
27
27
|
import { validateMetricsChange } from './metrics-deps.mjs';
|
|
28
28
|
import { validatePolicyChange } from './policy-deps.mjs';
|
|
29
29
|
import { validateModelChange } from './model-deps.mjs';
|
|
30
|
+
import { validateCloneProposal } from './clone-deps.mjs';
|
|
30
31
|
import { validateScheduleChange } from './schedule-deps.mjs';
|
|
31
32
|
import { lookupTask } from './source-deps.mjs';
|
|
32
33
|
import { effectiveTimeZone } from './schedule-spec.mjs';
|
|
@@ -53,10 +54,13 @@ class AskTurn extends EventEmitter {
|
|
|
53
54
|
pinnedScope = null,
|
|
54
55
|
memoryProject = null,
|
|
55
56
|
timeZone = null,
|
|
57
|
+
reader = null,
|
|
56
58
|
deps = {},
|
|
57
59
|
} = {}) {
|
|
58
60
|
super();
|
|
59
61
|
this.threadId = threadId;
|
|
62
|
+
// A shared sign-in's name (identity.mjs): the MCP child's per-person reads (notifications).
|
|
63
|
+
this.reader = typeof reader === 'string' && reader ? reader : null;
|
|
60
64
|
this.assistantMessageId = assistantMessageId;
|
|
61
65
|
this.userMessageId = userMessageId;
|
|
62
66
|
this.prompt = prompt;
|
|
@@ -93,6 +97,7 @@ class AskTurn extends EventEmitter {
|
|
|
93
97
|
validatePolicyChange: deps.validatePolicyChange ?? validatePolicyChange,
|
|
94
98
|
validateScheduleChange: deps.validateScheduleChange ?? validateScheduleChange,
|
|
95
99
|
validateModelChange: deps.validateModelChange ?? validateModelChange,
|
|
100
|
+
validateCloneProposal: deps.validateCloneProposal ?? validateCloneProposal,
|
|
96
101
|
scheduleDefaults: deps.scheduleDefaults ?? scheduleDefaults,
|
|
97
102
|
// A proposed plugin task is looked up here, once: it must exist, and the card shows its title.
|
|
98
103
|
lookupTask: deps.lookupTask === undefined ? lookupTask : deps.lookupTask,
|
|
@@ -344,6 +349,30 @@ class AskTurn extends EventEmitter {
|
|
|
344
349
|
this._persistBlocks();
|
|
345
350
|
}
|
|
346
351
|
|
|
352
|
+
/**
|
|
353
|
+
* propose_clone_project RESULT: the model card's split — the child validated for the model, the parent
|
|
354
|
+
* re-validates the same INPUT (and adds how GitHub is reached) and mints the card. A child {ok:false}
|
|
355
|
+
* already reached the model as text: no card, no notice.
|
|
356
|
+
*/
|
|
357
|
+
async _onCloneProposal(input, text, isError) {
|
|
358
|
+
if (isError) return;
|
|
359
|
+
let out = null;
|
|
360
|
+
try { out = JSON.parse(text); } catch { out = null; }
|
|
361
|
+
if (!out || out.ok !== true) return;
|
|
362
|
+
const d = this.deps;
|
|
363
|
+
try {
|
|
364
|
+
const r = await d.validateCloneProposal(input && typeof input === 'object' ? input : {});
|
|
365
|
+
if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
|
|
366
|
+
else {
|
|
367
|
+
const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
|
|
368
|
+
this.reducer.addBlock({ kind: 'notice', text: `Clone rejected: ${errors.join('; ')}` });
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
this.reducer.addBlock({ kind: 'notice', text: `Clone rejected: ${err?.message || err}` });
|
|
372
|
+
}
|
|
373
|
+
this._persistBlocks();
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
/** The card exists from the tool_use on (spec §8.2, PD7): a building block with the four-step trace, persisted. */
|
|
348
377
|
_onWorkflowStart(toolUseId, input) {
|
|
349
378
|
const d = this.deps;
|
|
@@ -431,6 +460,7 @@ class AskTurn extends EventEmitter {
|
|
|
431
460
|
onPolicyProposal: ({ input, text, isError }) => this._onPolicyProposal(input, text, isError),
|
|
432
461
|
onScheduleProposal: ({ input, text, isError }) => this._onScheduleProposal(input, text, isError),
|
|
433
462
|
onModelProposal: ({ input, text, isError }) => this._onModelProposal(input, text, isError),
|
|
463
|
+
onCloneProposal: ({ input, text, isError }) => this._onCloneProposal(input, text, isError),
|
|
434
464
|
// pause / resume / skip / mark-read in the child → the server's schedules-changed frames.
|
|
435
465
|
onScheduleMutation: (e) => { try { this.deps.onScheduleMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
436
466
|
// The MCP child cannot broadcast; the parent turns its comment writes into
|
|
@@ -583,7 +613,7 @@ class AskTurn extends EventEmitter {
|
|
|
583
613
|
mcpConfigPath = join(scratchDir, `mcp-${this.assistantMessageId}.json`);
|
|
584
614
|
await d.fs.writeFile(
|
|
585
615
|
mcpConfigPath,
|
|
586
|
-
JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath }), null, 2),
|
|
616
|
+
JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath, ...(this.reader ? { reader: this.reader } : {}) }), null, 2),
|
|
587
617
|
'utf8',
|
|
588
618
|
);
|
|
589
619
|
// One 30-minute budget for the whole turn, retry included. The timedOut
|
|
@@ -11,7 +11,7 @@ export const PAYLOAD_CEILING_BYTES = 2_500_000;
|
|
|
11
11
|
/** The phrase Claude Code matches on to trigger its own compaction. */
|
|
12
12
|
export const PROMPT_TOO_LONG = 'prompt is too long';
|
|
13
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;
|
|
14
|
+
const CONTEXT_RE = /context.?length|too long|maximum context|token limit|exceeds? .*tokens|too many tokens|request too large|payload too large|exceeds the context window/i;
|
|
15
15
|
|
|
16
16
|
/** Envelope for a status/type/message triple. */
|
|
17
17
|
export function anthropicError(status, type, message) {
|
|
@@ -30,6 +30,51 @@ export function upstreamMessage(text) {
|
|
|
30
30
|
return String(text).slice(0, 500);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The machine-readable code in an upstream error body ('' when there is none). */
|
|
34
|
+
export function upstreamCode(text) {
|
|
35
|
+
if (!text) return '';
|
|
36
|
+
try {
|
|
37
|
+
const j = JSON.parse(text);
|
|
38
|
+
const e = j && (j.error || j);
|
|
39
|
+
return e && typeof e === 'object' && typeof e.code === 'string' ? e.code : '';
|
|
40
|
+
} catch { return ''; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An upstream saying it serves this model through the other OpenAI protocol (Copilot: unsupported_api_for_model). */
|
|
44
|
+
const UNSUPPORTED_API_RE = /not accessible via the \/(chat\/completions|responses) endpoint|does not support (the )?Responses API/i;
|
|
45
|
+
|
|
46
|
+
/** The fix that refusal needs, per provider. */
|
|
47
|
+
export function unsupportedApiFix(provider) {
|
|
48
|
+
return provider === 'copilot'
|
|
49
|
+
? 'this model needs a different API: re-import it (Settings › Models › Import models…) or change its API in the model editor'
|
|
50
|
+
: 'this model needs a different API: change its API in the model editor';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Error codes for a prompt past the model's context window: OpenAI's, and Copilot's own limit check. */
|
|
54
|
+
const CONTEXT_CODES = new Set(['context_length_exceeded', 'model_max_prompt_tokens_exceeded']);
|
|
55
|
+
|
|
56
|
+
/** Whether an upstream error — by its code or its message — says the prompt overflowed the context window. */
|
|
57
|
+
export function isContextOverflow(code, message) {
|
|
58
|
+
return CONTEXT_CODES.has(String(code || '')) || CONTEXT_RE.test(String(message || ''));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A code or wording that names another failure: a rate limit, a quota, a server error, a timeout. */
|
|
62
|
+
const OTHER_FAILURE_RE = /rate.?limit|quota|server_error|overloaded|unavailable|timed? ?out|timeout|per min(ute)?\b|\bTPM\b/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* isContextOverflow for a failure reported inside an HTTP 200 — a Responses
|
|
66
|
+
* stream's response.failed / error, or a buffered body with status "failed".
|
|
67
|
+
* No status narrows it to a 400 there, so a code or wording that names another
|
|
68
|
+
* failure outranks CONTEXT_RE's broad phrases ("too long", "request too
|
|
69
|
+
* large"): a rate limit or a timeout must never make the CLI compact.
|
|
70
|
+
*/
|
|
71
|
+
export function isFailedResponseOverflow(code, message) {
|
|
72
|
+
const c = String(code || '');
|
|
73
|
+
const m = String(message || '');
|
|
74
|
+
if (CONTEXT_CODES.has(c)) return true;
|
|
75
|
+
return !OTHER_FAILURE_RE.test(c) && !OTHER_FAILURE_RE.test(m) && CONTEXT_RE.test(m);
|
|
76
|
+
}
|
|
77
|
+
|
|
33
78
|
/**
|
|
34
79
|
* Map an upstream HTTP failure to the envelope.
|
|
35
80
|
* @param {number} status upstream status
|
|
@@ -47,9 +92,12 @@ export function mapUpstreamError(status, text, { provider = 'upstream', retryAft
|
|
|
47
92
|
if (retryAfter) e.headers = { 'retry-after': String(retryAfter) };
|
|
48
93
|
return e;
|
|
49
94
|
}
|
|
50
|
-
if (status === 413 || (status === 400 &&
|
|
95
|
+
if (status === 413 || (status === 400 && isContextOverflow(upstreamCode(text), msg))) {
|
|
51
96
|
return anthropicError(400, 'invalid_request_error', PROMPT_TOO_LONG);
|
|
52
97
|
}
|
|
98
|
+
if (status >= 400 && status < 500 && (upstreamCode(text) === 'unsupported_api_for_model' || UNSUPPORTED_API_RE.test(msg))) {
|
|
99
|
+
return anthropicError(400, 'invalid_request_error', `${who}: ${msg || 'the model is not served through this API'} — ${unsupportedApiFix(who)}`);
|
|
100
|
+
}
|
|
53
101
|
if (status === 404) {
|
|
54
102
|
return anthropicError(400, 'invalid_request_error', `${who}: model or endpoint not found (404)${msg ? ` — ${msg}` : ''}`);
|
|
55
103
|
}
|
|
@@ -11,10 +11,10 @@ import {
|
|
|
11
11
|
copilotTermsAcknowledged, acknowledgeCopilotTerms, clearCopilotSignIn,
|
|
12
12
|
listGlobalModels, addGlobalModel, updateGlobalModel,
|
|
13
13
|
} from '../settings.mjs';
|
|
14
|
-
import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDERS, isUpstreamBaseUrl } from '../model-env.mjs';
|
|
14
|
+
import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDERS, isUpstreamBaseUrl, EFFORTS } from '../model-env.mjs';
|
|
15
15
|
import {
|
|
16
16
|
startDeviceFlow, pollDeviceFlow, githubLogin, copilotToken, invalidateCopilotToken,
|
|
17
|
-
listCopilotModels, copilotUsage, catalogEntryForCopilotModel,
|
|
17
|
+
listCopilotModels, copilotUsage, catalogEntryForCopilotModel, copilotApiFor,
|
|
18
18
|
} from './providers/copilot.mjs';
|
|
19
19
|
import { keyOptional } from './registry.mjs';
|
|
20
20
|
import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
|
|
@@ -139,14 +139,15 @@ export async function copilotModelsForImport({ fetch: f } = {}) {
|
|
|
139
139
|
if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
|
|
140
140
|
const list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
|
|
141
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()) }))
|
|
142
|
+
return list.map((m) => ({ ...m, api: copilotApiFor(m), catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
|
|
143
143
|
.sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* Import Copilot models into the catalog (§8.4). A new id gets the full
|
|
148
|
-
* import shape; an existing `copilot-*` id
|
|
149
|
-
*
|
|
148
|
+
* import shape; an existing `copilot-*` id has its api, upstream model and
|
|
149
|
+
* capabilities refreshed, and its efforts widened only when they are the old
|
|
150
|
+
* importer's automatic medium-only default — a user-edited label, efforts or
|
|
150
151
|
* pricing is never overwritten.
|
|
151
152
|
* @param {string[]} ids Copilot model ids (not catalog ids)
|
|
152
153
|
* @returns {Promise<{created:string[], updated:string[], skipped:string[]}>}
|
|
@@ -164,7 +165,19 @@ export async function importCopilotModels(ids, { fetch: f } = {}) {
|
|
|
164
165
|
if (m.inCatalog) {
|
|
165
166
|
const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
|
|
166
167
|
if (!current || !current.upstream || current.upstream.provider !== 'copilot') { skipped.push(id); continue; }
|
|
167
|
-
|
|
168
|
+
const patch = { upstream: { ...current.upstream, api: entry.upstream.api, model: entry.upstream.model, capabilities: entry.upstream.capabilities } };
|
|
169
|
+
// Before Copilot listed effort levels, a translated model the id regex missed
|
|
170
|
+
// was stored non-reasoning and trimmed to medium. Widen exactly that automatic
|
|
171
|
+
// default — never a list the user chose: a reasoning entry pinned to medium
|
|
172
|
+
// stays, and an Anthropic entry was never trimmed, so its medium is the user's.
|
|
173
|
+
const autoMediumOnly = current.upstream.api !== 'anthropic' && entry.upstream.api !== 'anthropic'
|
|
174
|
+
&& Array.isArray(current.efforts) && current.efforts.length === 1 && current.efforts[0] === 'medium'
|
|
175
|
+
&& !(current.upstream.capabilities && current.upstream.capabilities.reasoning === true);
|
|
176
|
+
if (autoMediumOnly) {
|
|
177
|
+
const next = entry.efforts === undefined ? [...EFFORTS] : entry.efforts;
|
|
178
|
+
if (!(next.length === 1 && next[0] === 'medium')) patch.efforts = next;
|
|
179
|
+
}
|
|
180
|
+
await updateGlobalModel(current.id, patch);
|
|
168
181
|
updated.push(current.id);
|
|
169
182
|
} else {
|
|
170
183
|
await addGlobalModel(entry);
|
|
@@ -206,7 +219,7 @@ export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
|
|
|
206
219
|
|
|
207
220
|
/**
|
|
208
221
|
* 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
|
|
222
|
+
* label, efforts and pricing you edited and only has its upstream refreshed.
|
|
210
223
|
* @param {string[]} ids model ids as the endpoint reports them (not catalog ids)
|
|
211
224
|
* @returns {Promise<{created:string[], updated:string[], skipped:Array<{id:string, why:string}>, server:string, baseUrl:string}>}
|
|
212
225
|
*/
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// Every network call takes an injectable `fetch` so the whole module is
|
|
13
13
|
// testable against a stub without touching github.com.
|
|
14
14
|
|
|
15
|
+
import { EFFORTS, REASONING_EFFORT_LEVELS } from '../../model-env.mjs';
|
|
16
|
+
|
|
15
17
|
export const GITHUB_CLIENT_ID = 'Iv1.b507a08c87ecfe98';
|
|
16
18
|
export const GITHUB_SCOPES = 'read:user';
|
|
17
19
|
export const GITHUB_BASE = 'https://github.com';
|
|
@@ -84,7 +86,11 @@ export function bodyHasImage(body) {
|
|
|
84
86
|
/** 'agent' when the last message continues a tool loop, else 'user' (§7.1). */
|
|
85
87
|
export function requestInitiator(body) {
|
|
86
88
|
const msgs = body && Array.isArray(body.messages) ? body.messages : [];
|
|
87
|
-
|
|
89
|
+
// The CLI appends its mid-conversation system reminders AFTER the turn they
|
|
90
|
+
// ride on (…, user[tool_result], system): the turn is the last message before them.
|
|
91
|
+
let i = msgs.length - 1;
|
|
92
|
+
while (i >= 0 && msgs[i] && msgs[i].role === 'system') i -= 1;
|
|
93
|
+
const last = msgs[i];
|
|
88
94
|
if (!last) return 'user';
|
|
89
95
|
if (last.role === 'assistant') return 'agent';
|
|
90
96
|
if (Array.isArray(last.content) && last.content.some((b) => b?.type === 'tool_result')) return 'agent';
|
|
@@ -190,7 +196,12 @@ export function normalizeCopilotModel(m) {
|
|
|
190
196
|
const supports = caps.supports && typeof caps.supports === 'object' ? caps.supports : {};
|
|
191
197
|
const limits = caps.limits && typeof caps.limits === 'object' ? caps.limits : {};
|
|
192
198
|
const vendor = typeof m.vendor === 'string' ? m.vendor : '';
|
|
193
|
-
|
|
199
|
+
// Copilot lists a model's reasoning-effort levels (an array); older payloads sent `true`.
|
|
200
|
+
const efforts = Array.isArray(supports.reasoning_effort)
|
|
201
|
+
? REASONING_EFFORT_LEVELS.filter((e) => supports.reasoning_effort.includes(e))
|
|
202
|
+
: null;
|
|
203
|
+
const reasoning = (efforts !== null && efforts.some((e) => e !== 'none'))
|
|
204
|
+
|| supports.reasoning_effort === true || supports.thinking === true || REASONING_ID_RE.test(m.id);
|
|
194
205
|
return {
|
|
195
206
|
id: m.id,
|
|
196
207
|
name: typeof m.name === 'string' && m.name ? m.name : m.id,
|
|
@@ -203,6 +214,8 @@ export function normalizeCopilotModel(m) {
|
|
|
203
214
|
toolCalls: supports.tool_calls !== false,
|
|
204
215
|
vision: supports.vision === true,
|
|
205
216
|
reasoning,
|
|
217
|
+
reasoningEfforts: efforts && efforts.length ? efforts : null,
|
|
218
|
+
endpoints: Array.isArray(m.supported_endpoints) ? m.supported_endpoints.filter((e) => typeof e === 'string') : null,
|
|
206
219
|
maxPromptTokens: Number(limits.max_prompt_tokens) || null,
|
|
207
220
|
maxOutputTokens: Number(limits.max_output_tokens) || null,
|
|
208
221
|
contextWindow: Number(limits.max_context_window_tokens) || null,
|
|
@@ -214,19 +227,52 @@ export function isAnthropicVendor(m) {
|
|
|
214
227
|
return /anthropic/i.test(m.vendor || '') || /^claude/i.test(m.id || '');
|
|
215
228
|
}
|
|
216
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The wire protocol an imported Copilot model is driven through
|
|
232
|
+
* (2026-09-23-bridge-openai-responses-design.md §11): Anthropic models pass
|
|
233
|
+
* through; an OpenAI model uses the Responses API whenever Copilot serves it
|
|
234
|
+
* there (its reasoning then carries across tool turns); any other model uses
|
|
235
|
+
* it only when Copilot serves it nowhere else; no endpoint list (legacy
|
|
236
|
+
* models) → chat completions.
|
|
237
|
+
*/
|
|
238
|
+
export function copilotApiFor(m) {
|
|
239
|
+
if (isAnthropicVendor(m)) return 'anthropic';
|
|
240
|
+
const eps = Array.isArray(m.endpoints) ? m.endpoints : null;
|
|
241
|
+
if (eps && eps.includes('/responses') && (/openai/i.test(m.vendor || '') || !eps.includes('/chat/completions'))) return 'openai-responses';
|
|
242
|
+
return 'openai-chat';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Worca efforts for an imported Copilot model: every effort (undefined) for an
|
|
247
|
+
* Anthropic model; the Worca efforts among the model's listed levels when
|
|
248
|
+
* Copilot lists them (all four → undefined; none in common → medium, which the
|
|
249
|
+
* bridge maps to the nearest listed level); else every effort for a reasoning
|
|
250
|
+
* model and medium otherwise.
|
|
251
|
+
*/
|
|
252
|
+
export function copilotEfforts(m) {
|
|
253
|
+
if (isAnthropicVendor(m)) return undefined;
|
|
254
|
+
if (Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length) {
|
|
255
|
+
const eff = EFFORTS.filter((e) => m.reasoningEfforts.includes(e));
|
|
256
|
+
if (!eff.length) return ['medium'];
|
|
257
|
+
return eff.length === EFFORTS.length ? undefined : eff;
|
|
258
|
+
}
|
|
259
|
+
return m.reasoning ? undefined : ['medium'];
|
|
260
|
+
}
|
|
261
|
+
|
|
217
262
|
/** The catalog entry an import creates for a Copilot model (§8.4). */
|
|
218
263
|
export function catalogEntryForCopilotModel(m) {
|
|
219
|
-
const
|
|
264
|
+
const api = copilotApiFor(m);
|
|
220
265
|
const capabilities = {
|
|
221
266
|
toolCalls: m.toolCalls, vision: m.vision, reasoning: m.reasoning,
|
|
222
267
|
...(m.maxPromptTokens ? { maxPromptTokens: m.maxPromptTokens } : {}),
|
|
223
268
|
...(m.maxOutputTokens ? { maxOutputTokens: m.maxOutputTokens } : {}),
|
|
269
|
+
...(api !== 'anthropic' && Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length ? { reasoningEfforts: m.reasoningEfforts } : {}),
|
|
224
270
|
};
|
|
225
271
|
return {
|
|
226
272
|
id: `copilot-${m.id}`,
|
|
227
273
|
label: `${m.name} (Copilot)`,
|
|
228
|
-
efforts:
|
|
229
|
-
upstream: { provider: 'copilot', api
|
|
274
|
+
efforts: copilotEfforts(m),
|
|
275
|
+
upstream: { provider: 'copilot', api, model: m.id, capabilities },
|
|
230
276
|
cost: { free: true },
|
|
231
277
|
};
|
|
232
278
|
}
|