atom-agent 1.2.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/CHANGELOG.md +97 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +3 -2
- package/dist/ui/diff.js +7 -52
- package/dist/ui/modals.js +5 -5
- package/dist/ui/palette.js +1 -1
- package/dist/ui/side-by-side.js +7 -5
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/transcript.js +3 -3
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/zen.js
CHANGED
|
@@ -5,14 +5,20 @@
|
|
|
5
5
|
// different Zen request shapes and are out of scope.
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { MAX_TOOL_STEPS,
|
|
8
|
+
import { MAX_TOOL_STEPS, allToolDefinitions, getExtensionPromptHints, } from "./tools.js";
|
|
9
9
|
import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
|
|
10
10
|
import { discoverLocalProvider } from "./local-discovery.js";
|
|
11
|
-
import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
|
|
11
|
+
import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
|
|
12
12
|
export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
|
|
13
13
|
import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
|
|
14
14
|
import { splitSystemHead } from "./prompt-cache.js";
|
|
15
15
|
import { SYSTEM_PROMPT } from "./system.js";
|
|
16
|
+
// Provider hooks (ticket 08): extension context/pre-request/post-response
|
|
17
|
+
// hooks fire per POST in the three transports below (openai-chat,
|
|
18
|
+
// anthropic-messages, gemini-generate), so every provider kind is covered
|
|
19
|
+
// through the chatCompletionForProvider dispatcher. All apply/notify helpers
|
|
20
|
+
// never throw (fail-open), so hooks can never break the turn.
|
|
21
|
+
import { afterResponseObservers, applyBeforeRequest, applyContextTransform, beforeRequestInterceptors, contextTransformers, notifyAfterResponse, snapshotResponseHeaders, } from "./tools/provider-hooks.js";
|
|
16
22
|
export { MAX_TOOL_STEPS };
|
|
17
23
|
// Re-exported so existing `SYSTEM_PROMPT` imports keep working; the
|
|
18
24
|
// owner-editable source of truth lives in src/system.ts.
|
|
@@ -22,61 +28,66 @@ export const MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
|
|
|
22
28
|
// Task 5 default: strongest tool-reliable chat/completions default available,
|
|
23
29
|
// verified against the live /models list + https://opencode.ai/docs/zen on
|
|
24
30
|
// 2026-09-08 (endpoint chat/completions, Tool Calls support, not deprecated,
|
|
25
|
-
//
|
|
26
|
-
//
|
|
31
|
+
// verified 1M context window). Free models (big-pickle etc.) stay in
|
|
32
|
+
// FALLBACK_MODELS, selectable via /model.
|
|
27
33
|
export const DEFAULT_MODEL = "deepseek-v4-pro";
|
|
28
34
|
export const AGENTS_CHAR_CAP = 12 * 1024;
|
|
29
|
-
// ---- Conversation
|
|
30
|
-
// Long sessions
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// (when valid) → project atom.json → global atom.json → compiled default:
|
|
35
|
-
// - ATOM_MAX_HISTORY_MESSAGES, clamped to 10–1000 (default 100)
|
|
36
|
-
// - ATOM_MAX_HISTORY_CHARS, clamped to 10_000–2_000_000 (default 200_000)
|
|
35
|
+
// ---- Conversation history: uncapped ----
|
|
36
|
+
// Long sessions ride on compaction, not truncation: the shared loop core
|
|
37
|
+
// sends the full history on every POST (uniform across providers) and
|
|
38
|
+
// auto-compact at ~83% of the verified window is the only pressure valve.
|
|
39
|
+
// There are no message/char caps and no trim step.
|
|
37
40
|
export { toolStepBudget } from "./agent/loop.js";
|
|
38
|
-
// Reasoning effort (session state in the App, default "
|
|
39
|
-
// Wire values are
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// the
|
|
41
|
+
// Reasoning effort (session state in the App, default "auto").
|
|
42
|
+
// Wire values are low/medium/high/max. "auto" never sends a param: it lets
|
|
43
|
+
// the model decide. The top setting is `Max`, sent on the wire as `max`.
|
|
44
|
+
// Support is assumed for every model on every provider kind — the server is
|
|
45
|
+
// authoritative: a model that truly lacks the knob fails the POST with a
|
|
46
|
+
// 400 naming the effort param, and the transports below retry once without
|
|
47
|
+
// it (see isEffortRejection in adapters.ts). Nothing is preemptively gated
|
|
48
|
+
// by model name, so "(unsupported)" only ever reflects an actual rejection.
|
|
44
49
|
export const EFFORT_OPTIONS = [
|
|
45
|
-
"
|
|
50
|
+
"auto",
|
|
46
51
|
"low",
|
|
47
52
|
"medium",
|
|
48
53
|
"high",
|
|
49
54
|
"max",
|
|
50
55
|
];
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
export
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
// Canonicalize a stored/picked effort value. "default" is the pre-auto name
|
|
57
|
+
// for the same level (old saves, old atom.json) and maps to "auto"; unknown
|
|
58
|
+
// values fall back to "auto" instead of stranding the session.
|
|
59
|
+
export function normalizeEffort(value) {
|
|
60
|
+
if (value === "default" || value === "auto")
|
|
61
|
+
return "auto";
|
|
62
|
+
if (value === "low" || value === "medium" || value === "high" || value === "max") {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
return "auto";
|
|
66
|
+
}
|
|
67
|
+
// Effort support is provider-wide, never per-model: every known provider
|
|
68
|
+
// kind has a wire mapping (reasoning_effort on openai-chat, thinking on
|
|
69
|
+
// anthropic-messages, thinkingLevel on gemini-generate). Returns false only
|
|
70
|
+
// for an empty model or an unknown provider id — the actual per-model truth
|
|
71
|
+
// comes from the server at POST time (see above).
|
|
72
|
+
export function isEffortSupported(model, provider) {
|
|
73
|
+
if (!model)
|
|
74
|
+
return false;
|
|
75
|
+
if (provider === undefined)
|
|
76
|
+
return true;
|
|
77
|
+
return getProvider(provider) !== undefined;
|
|
64
78
|
}
|
|
65
79
|
// Wire value for the POST body, or undefined when the param must be
|
|
66
|
-
// omitted (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
// omitted (Auto, or an unknown effort string). The `model` argument is
|
|
81
|
+
// accepted for backward compatibility and intentionally ignored: support is
|
|
82
|
+
// assumed for every model, with server rejection as the only veto.
|
|
83
|
+
export function reasoningEffortParam(effort, _model) {
|
|
84
|
+
const normalized = normalizeEffort(effort);
|
|
85
|
+
if (normalized === "auto")
|
|
71
86
|
return undefined;
|
|
72
|
-
|
|
73
|
-
return effort;
|
|
74
|
-
}
|
|
75
|
-
return undefined;
|
|
87
|
+
return normalized;
|
|
76
88
|
}
|
|
77
|
-
export { CHARS_PER_TOKEN,
|
|
89
|
+
export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
|
|
78
90
|
export { openTodoNeedles } from "./agent/gates.js";
|
|
79
|
-
export { truncateHistory } from "./agent/loop.js";
|
|
80
91
|
function finiteCount(value) {
|
|
81
92
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
82
93
|
? Math.floor(value)
|
|
@@ -727,9 +738,11 @@ export async function readSSEMessage(res, opts) {
|
|
|
727
738
|
}
|
|
728
739
|
// Streaming chat POST with tools attached (tool_choice omitted, so the
|
|
729
740
|
// default auto applies). Sends {..., stream:true} plus `reasoning_effort`
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
741
|
+
// whenever opts.reasoningEffort is non-Auto (see reasoningEffortParam) —
|
|
742
|
+
// for every model, on every provider routed through this transport.
|
|
743
|
+
// A 400 naming the knob means the model truly lacks it: warn once via
|
|
744
|
+
// onWarning and retry without it. Parses the SSE event stream (see
|
|
745
|
+
// readSSEMessage).
|
|
733
746
|
// When the response has no SSE body (plain {ok, json()} mocks and other
|
|
734
747
|
// non-streaming payloads) it falls back to the original single-JSON parse,
|
|
735
748
|
// unchanged. Returns the raw assistant message: either final content or
|
|
@@ -750,10 +763,25 @@ export async function readSSEMessage(res, opts) {
|
|
|
750
763
|
// the dispatcher passes providerLabel(provider) for non-zen openai-chat
|
|
751
764
|
// providers so users see e.g. `OpenAI HTTP 401` instead of `Zen HTTP 401`.
|
|
752
765
|
// Legacy `function_call` shape is intentionally ignored.
|
|
753
|
-
export async function chatCompletion(endpoint, apiKey, model, history,
|
|
766
|
+
export async function chatCompletion(endpoint, apiKey, model, history,
|
|
767
|
+
// providerId (ticket 08): hook attribution for the shared openai-chat
|
|
768
|
+
// transport — the dispatcher passes its provider id, direct zen callers
|
|
769
|
+
// omit it and default to "opencode-zen". Optional, wire-compatible.
|
|
770
|
+
opts, errorLabel = "Zen") {
|
|
754
771
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
755
772
|
const signal = opts?.signal ?? null;
|
|
773
|
+
const hookProvider = opts?.providerId ?? "opencode-zen";
|
|
774
|
+
// Context hooks (ticket 08) run once per call — not per retry — over a
|
|
775
|
+
// per-POST copy; the loop transcript array is never mutated. Fail-open:
|
|
776
|
+
// a throwing handler degrades to the untransformed messages. Zero-cost
|
|
777
|
+
// when no hooks are registered: the apply path is skipped entirely (no
|
|
778
|
+
// extra awaits per POST), so hook-free turns keep byte-identical timing.
|
|
779
|
+
const contextHooks = contextTransformers();
|
|
780
|
+
const outgoingHistory = contextHooks.length > 0 ? await applyContextTransform(contextHooks, history) : history;
|
|
756
781
|
let lastError = null;
|
|
782
|
+
// Server-authoritative unsupported: when a 400 names the effort knob, the
|
|
783
|
+
// flag below drops it and the loop retries without it (once per call).
|
|
784
|
+
let effortDropped = false;
|
|
757
785
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
758
786
|
try {
|
|
759
787
|
throwIfCancelled(signal);
|
|
@@ -763,7 +791,9 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
763
791
|
catch {
|
|
764
792
|
// ignore observer errors
|
|
765
793
|
}
|
|
766
|
-
const effortParam =
|
|
794
|
+
const effortParam = effortDropped
|
|
795
|
+
? undefined
|
|
796
|
+
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
767
797
|
const summaryOpts = opts;
|
|
768
798
|
// Stable-prefix split (prompt-cache architecture): history[0]'s env
|
|
769
799
|
// tail becomes its own system message so the stable head + tools stay
|
|
@@ -771,16 +801,18 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
771
801
|
// system messages concatenate on every OpenAI-protocol server, so this
|
|
772
802
|
// is content-neutral. No env tail (tests, old saves) → history passes
|
|
773
803
|
// through untouched, byte-identical to before.
|
|
774
|
-
const messages = splitSystemHead(
|
|
804
|
+
const messages = splitSystemHead(outgoingHistory);
|
|
775
805
|
const payload = {
|
|
776
806
|
model,
|
|
777
807
|
messages,
|
|
778
808
|
stream: true,
|
|
779
809
|
};
|
|
780
810
|
// Compaction path only: tools disabled means NO `tools` key at all
|
|
781
|
-
// (asserted in tests); the normal loop always sends the schema
|
|
811
|
+
// (asserted in tests); the normal loop always sends the schema —
|
|
812
|
+
// builtins plus extension-registered custom tools, so the model can
|
|
813
|
+
// discover and call them exactly like builtins.
|
|
782
814
|
if (!summaryOpts?.disableTools) {
|
|
783
|
-
payload["tools"] =
|
|
815
|
+
payload["tools"] = allToolDefinitions();
|
|
784
816
|
}
|
|
785
817
|
// Compaction path only: cap output (openai-chat kind uses max_tokens).
|
|
786
818
|
if (typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
@@ -790,21 +822,73 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
790
822
|
}
|
|
791
823
|
if (effortParam !== undefined)
|
|
792
824
|
payload["reasoning_effort"] = effortParam;
|
|
825
|
+
// Pre-request hooks (ticket 08) run per POST attempt: payload
|
|
826
|
+
// replacement must be a record (else ignored — downstream JSON/fetch
|
|
827
|
+
// handling is never bypassed); header merge honors deletions.
|
|
828
|
+
// Zero-cost when unregistered (see context hooks above).
|
|
829
|
+
const preHooks = beforeRequestInterceptors();
|
|
830
|
+
const outgoing = preHooks.length > 0
|
|
831
|
+
? await applyBeforeRequest(preHooks, {
|
|
832
|
+
provider: hookProvider,
|
|
833
|
+
model,
|
|
834
|
+
url: endpoint,
|
|
835
|
+
payload,
|
|
836
|
+
headers: {
|
|
837
|
+
"Content-Type": "application/json",
|
|
838
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
839
|
+
},
|
|
840
|
+
})
|
|
841
|
+
: {
|
|
842
|
+
payload,
|
|
843
|
+
headers: {
|
|
844
|
+
"Content-Type": "application/json",
|
|
845
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
846
|
+
},
|
|
847
|
+
};
|
|
793
848
|
const res = await fetch(endpoint, {
|
|
794
849
|
method: "POST",
|
|
795
850
|
// Anonymous-capable providers (Kilo free models) omit Authorization
|
|
796
851
|
// when no key is configured — never an empty `Bearer `. Keyed
|
|
797
852
|
// providers always pass a key (gated by providerNeedsKey), so their
|
|
798
853
|
// behavior is unchanged.
|
|
799
|
-
headers:
|
|
800
|
-
|
|
801
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
802
|
-
},
|
|
803
|
-
body: JSON.stringify(payload),
|
|
854
|
+
headers: outgoing.headers,
|
|
855
|
+
body: JSON.stringify(outgoing.payload),
|
|
804
856
|
...(signal ? { signal } : {}),
|
|
805
857
|
});
|
|
858
|
+
// Post-response observers (ticket 08): every resolved POST (ok and
|
|
859
|
+
// HTTP-error alike), fail-open — never break the turn. Zero-cost when
|
|
860
|
+
// unregistered (the header snapshot is only built for live observers).
|
|
861
|
+
const postHooks = afterResponseObservers();
|
|
862
|
+
if (postHooks.length > 0) {
|
|
863
|
+
await notifyAfterResponse(postHooks, {
|
|
864
|
+
provider: hookProvider,
|
|
865
|
+
model,
|
|
866
|
+
url: endpoint,
|
|
867
|
+
status: res.status,
|
|
868
|
+
ok: res.ok,
|
|
869
|
+
headers: snapshotResponseHeaders(res),
|
|
870
|
+
});
|
|
871
|
+
}
|
|
806
872
|
if (!res.ok) {
|
|
807
873
|
const errText = await safeErrorText(res);
|
|
874
|
+
// The server is the authority on effort support: a 400 naming the
|
|
875
|
+
// knob means this model/deployment has no such control — warn,
|
|
876
|
+
// drop the knob, and retry without it (setting kept). Any other
|
|
877
|
+
// 400 keeps failing loudly below.
|
|
878
|
+
if (res.status === 400 &&
|
|
879
|
+
effortParam !== undefined &&
|
|
880
|
+
!effortDropped &&
|
|
881
|
+
isEffortRejection(errText)) {
|
|
882
|
+
effortDropped = true;
|
|
883
|
+
try {
|
|
884
|
+
opts?.onWarning?.(`reasoning effort "${effortParam}" is not supported by ${model} — continuing without it`);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
// ignore observer errors
|
|
888
|
+
}
|
|
889
|
+
throwIfCancelled(signal);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
808
892
|
const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
|
|
809
893
|
if (!RETRYABLE_STATUS.has(res.status))
|
|
810
894
|
throw err;
|
|
@@ -956,11 +1040,16 @@ export function loadAgentsPrompt(cwd = process.cwd()) {
|
|
|
956
1040
|
}
|
|
957
1041
|
}
|
|
958
1042
|
export function buildSystemPrompt(cwd = process.cwd()) {
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
1043
|
+
// Three layers: src/system.ts base one-liner + repo AGENTS.md overlay +
|
|
1044
|
+
// extension prompt hints (ticket 06). The hints ride the existing assembly
|
|
1045
|
+
// — no parallel prompt pipeline: when none are registered the result is
|
|
1046
|
+
// byte-identical to the two-layer form.
|
|
962
1047
|
const extra = loadAgentsPrompt(cwd);
|
|
963
|
-
|
|
1048
|
+
const base = extra ? `${SYSTEM_PROMPT}\n\n${extra}` : SYSTEM_PROMPT;
|
|
1049
|
+
const hints = getExtensionPromptHints();
|
|
1050
|
+
if (hints.length === 0)
|
|
1051
|
+
return base;
|
|
1052
|
+
return `${base}\n\n## Extension hints\n${hints.map((h) => `- ${h}`).join("\n")}`;
|
|
964
1053
|
}
|
|
965
1054
|
function providerHttpError(provider, status, text) {
|
|
966
1055
|
return new Error(`${providerLabel(provider)} HTTP ${status}: ${text.slice(0, 300)}`);
|
|
@@ -968,7 +1057,17 @@ function providerHttpError(provider, status, text) {
|
|
|
968
1057
|
export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
969
1058
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
970
1059
|
const signal = opts?.signal ?? null;
|
|
1060
|
+
// Ticket 08: same per-POST hook contract as the openai-chat path above
|
|
1061
|
+
// (context once per call, pre/post per attempt, all fail-open, zero-cost
|
|
1062
|
+
// when unregistered).
|
|
1063
|
+
const anthropicContextHooks = contextTransformers();
|
|
1064
|
+
const outgoingHistory = anthropicContextHooks.length > 0
|
|
1065
|
+
? await applyContextTransform(anthropicContextHooks, history)
|
|
1066
|
+
: history;
|
|
971
1067
|
let lastError = null;
|
|
1068
|
+
// Same server-authoritative unsupported contract as the openai-chat path:
|
|
1069
|
+
// a 400 naming the thinking knob drops it for the rest of the call.
|
|
1070
|
+
let anthropicEffortDropped = false;
|
|
972
1071
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
973
1072
|
try {
|
|
974
1073
|
throwIfCancelled(signal);
|
|
@@ -979,7 +1078,7 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
979
1078
|
// ignore
|
|
980
1079
|
}
|
|
981
1080
|
const summaryOpts = opts;
|
|
982
|
-
const base = buildAnthropicBody(
|
|
1081
|
+
const base = buildAnthropicBody(outgoingHistory, model, {
|
|
983
1082
|
includeTools: !summaryOpts?.disableTools,
|
|
984
1083
|
});
|
|
985
1084
|
const body = { ...base, stream: true };
|
|
@@ -990,14 +1089,62 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
990
1089
|
summaryOpts.maxOutputTokens > 0) {
|
|
991
1090
|
body["max_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
|
|
992
1091
|
}
|
|
1092
|
+
// /effort maps to the native thinking budget (Auto omits it; a cap too
|
|
1093
|
+
// small for the 1024 minimum omits it too — see anthropicThinkingFor).
|
|
1094
|
+
const anthropicEffort = anthropicEffortDropped
|
|
1095
|
+
? undefined
|
|
1096
|
+
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
1097
|
+
const anthropicBudget = anthropicEffort !== undefined
|
|
1098
|
+
? anthropicThinkingFor(anthropicEffort, typeof body["max_tokens"] === "number"
|
|
1099
|
+
? body["max_tokens"]
|
|
1100
|
+
: ANTHROPIC_MAX_TOKENS)
|
|
1101
|
+
: undefined;
|
|
1102
|
+
if (anthropicBudget !== undefined) {
|
|
1103
|
+
body["thinking"] = { type: "enabled", budget_tokens: anthropicBudget };
|
|
1104
|
+
}
|
|
1105
|
+
const anthropicPreHooks = beforeRequestInterceptors();
|
|
1106
|
+
const outgoing = anthropicPreHooks.length > 0
|
|
1107
|
+
? await applyBeforeRequest(anthropicPreHooks, {
|
|
1108
|
+
provider: "anthropic",
|
|
1109
|
+
model,
|
|
1110
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
1111
|
+
payload: body,
|
|
1112
|
+
headers: anthropicHeaders(apiKey),
|
|
1113
|
+
})
|
|
1114
|
+
: { payload: body, headers: anthropicHeaders(apiKey) };
|
|
993
1115
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
994
1116
|
method: "POST",
|
|
995
|
-
headers:
|
|
996
|
-
body: JSON.stringify(
|
|
1117
|
+
headers: outgoing.headers,
|
|
1118
|
+
body: JSON.stringify(outgoing.payload),
|
|
997
1119
|
...(signal ? { signal } : {}),
|
|
998
1120
|
});
|
|
1121
|
+
const anthropicPostHooks = afterResponseObservers();
|
|
1122
|
+
if (anthropicPostHooks.length > 0) {
|
|
1123
|
+
await notifyAfterResponse(anthropicPostHooks, {
|
|
1124
|
+
provider: "anthropic",
|
|
1125
|
+
model,
|
|
1126
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
1127
|
+
status: res.status,
|
|
1128
|
+
ok: res.ok,
|
|
1129
|
+
headers: snapshotResponseHeaders(res),
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
999
1132
|
if (!res.ok) {
|
|
1000
1133
|
const errText = await safeErrorText(res);
|
|
1134
|
+
if (res.status === 400 &&
|
|
1135
|
+
anthropicBudget !== undefined &&
|
|
1136
|
+
!anthropicEffortDropped &&
|
|
1137
|
+
isEffortRejection(errText)) {
|
|
1138
|
+
anthropicEffortDropped = true;
|
|
1139
|
+
try {
|
|
1140
|
+
opts?.onWarning?.(`reasoning effort "${anthropicEffort}" is not supported by ${model} — continuing without it`);
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
// ignore observer errors
|
|
1144
|
+
}
|
|
1145
|
+
throwIfCancelled(signal);
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1001
1148
|
const err = providerHttpError("anthropic", res.status, errText);
|
|
1002
1149
|
if (!RETRYABLE_STATUS.has(res.status))
|
|
1003
1150
|
throw err;
|
|
@@ -1057,7 +1204,17 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
1057
1204
|
export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
1058
1205
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
1059
1206
|
const signal = opts?.signal ?? null;
|
|
1207
|
+
// Ticket 08: same per-POST hook contract as the openai-chat path above
|
|
1208
|
+
// (context once per call, pre/post per attempt, all fail-open, zero-cost
|
|
1209
|
+
// when unregistered).
|
|
1210
|
+
const geminiContextHooks = contextTransformers();
|
|
1211
|
+
const outgoingHistory = geminiContextHooks.length > 0
|
|
1212
|
+
? await applyContextTransform(geminiContextHooks, history)
|
|
1213
|
+
: history;
|
|
1060
1214
|
let lastError = null;
|
|
1215
|
+
// Same server-authoritative unsupported contract as the other paths: a
|
|
1216
|
+
// 400 naming the thinking knob drops it for the rest of the call.
|
|
1217
|
+
let geminiEffortDropped = false;
|
|
1061
1218
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1062
1219
|
try {
|
|
1063
1220
|
throwIfCancelled(signal);
|
|
@@ -1068,7 +1225,7 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1068
1225
|
// ignore
|
|
1069
1226
|
}
|
|
1070
1227
|
const summaryOpts = opts;
|
|
1071
|
-
const body = buildGeminiBody(
|
|
1228
|
+
const body = buildGeminiBody(outgoingHistory, model, {
|
|
1072
1229
|
includeTools: !summaryOpts?.disableTools,
|
|
1073
1230
|
...(typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
1074
1231
|
Number.isFinite(summaryOpts.maxOutputTokens) &&
|
|
@@ -1076,14 +1233,65 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1076
1233
|
? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
|
|
1077
1234
|
: {}),
|
|
1078
1235
|
});
|
|
1236
|
+
// /effort maps to the native thinkingLevel (Auto omits it; Max rides
|
|
1237
|
+
// high, the deepest level the API offers). Merged into
|
|
1238
|
+
// generationConfig so a compaction maxOutputTokens cap survives.
|
|
1239
|
+
const geminiEffort = geminiEffortDropped
|
|
1240
|
+
? undefined
|
|
1241
|
+
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
1242
|
+
const geminiLevel = geminiEffort !== undefined ? geminiThinkingLevelFor(geminiEffort) : undefined;
|
|
1243
|
+
if (geminiLevel !== undefined) {
|
|
1244
|
+
const gc = typeof body.generationConfig === "object" && body.generationConfig !== null
|
|
1245
|
+
? { ...body.generationConfig }
|
|
1246
|
+
: {};
|
|
1247
|
+
body.generationConfig = {
|
|
1248
|
+
...gc,
|
|
1249
|
+
thinkingConfig: { thinkingLevel: geminiLevel },
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
const geminiPreHooks = beforeRequestInterceptors();
|
|
1253
|
+
const outgoing = geminiPreHooks.length > 0
|
|
1254
|
+
? await applyBeforeRequest(geminiPreHooks, {
|
|
1255
|
+
provider: "google-gemini",
|
|
1256
|
+
model,
|
|
1257
|
+
url: geminiChatUrl(model),
|
|
1258
|
+
payload: body,
|
|
1259
|
+
headers: geminiHeaders(apiKey),
|
|
1260
|
+
})
|
|
1261
|
+
: { payload: body, headers: geminiHeaders(apiKey) };
|
|
1079
1262
|
const res = await fetch(geminiChatUrl(model), {
|
|
1080
1263
|
method: "POST",
|
|
1081
|
-
headers:
|
|
1082
|
-
body: JSON.stringify(
|
|
1264
|
+
headers: outgoing.headers,
|
|
1265
|
+
body: JSON.stringify(outgoing.payload),
|
|
1083
1266
|
...(signal ? { signal } : {}),
|
|
1084
1267
|
});
|
|
1268
|
+
const geminiPostHooks = afterResponseObservers();
|
|
1269
|
+
if (geminiPostHooks.length > 0) {
|
|
1270
|
+
await notifyAfterResponse(geminiPostHooks, {
|
|
1271
|
+
provider: "google-gemini",
|
|
1272
|
+
model,
|
|
1273
|
+
url: geminiChatUrl(model),
|
|
1274
|
+
status: res.status,
|
|
1275
|
+
ok: res.ok,
|
|
1276
|
+
headers: snapshotResponseHeaders(res),
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1085
1279
|
if (!res.ok) {
|
|
1086
1280
|
const errText = await safeErrorText(res);
|
|
1281
|
+
if (res.status === 400 &&
|
|
1282
|
+
geminiLevel !== undefined &&
|
|
1283
|
+
!geminiEffortDropped &&
|
|
1284
|
+
isEffortRejection(errText)) {
|
|
1285
|
+
geminiEffortDropped = true;
|
|
1286
|
+
try {
|
|
1287
|
+
opts?.onWarning?.(`reasoning effort "${geminiEffort}" is not supported by ${model} — continuing without it`);
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
// ignore observer errors
|
|
1291
|
+
}
|
|
1292
|
+
throwIfCancelled(signal);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1087
1295
|
const err = providerHttpError("google-gemini", res.status, errText);
|
|
1088
1296
|
if (!RETRYABLE_STATUS.has(res.status))
|
|
1089
1297
|
throw err;
|
|
@@ -1115,10 +1323,20 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1115
1323
|
throwIfCancelled(signal);
|
|
1116
1324
|
const res2 = await fetch(geminiGenerateUrl(model), {
|
|
1117
1325
|
method: "POST",
|
|
1118
|
-
headers:
|
|
1119
|
-
body: JSON.stringify(
|
|
1326
|
+
headers: outgoing.headers,
|
|
1327
|
+
body: JSON.stringify(outgoing.payload),
|
|
1120
1328
|
...(signal ? { signal } : {}),
|
|
1121
1329
|
});
|
|
1330
|
+
if (geminiPostHooks.length > 0) {
|
|
1331
|
+
await notifyAfterResponse(geminiPostHooks, {
|
|
1332
|
+
provider: "google-gemini",
|
|
1333
|
+
model,
|
|
1334
|
+
url: geminiGenerateUrl(model),
|
|
1335
|
+
status: res2.status,
|
|
1336
|
+
ok: res2.ok,
|
|
1337
|
+
headers: snapshotResponseHeaders(res2),
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1122
1340
|
if (!res2.ok) {
|
|
1123
1341
|
const errText2 = await safeErrorText(res2);
|
|
1124
1342
|
throw providerHttpError("google-gemini", res2.status, errText2);
|
|
@@ -1169,9 +1387,11 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1169
1387
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1170
1388
|
}
|
|
1171
1389
|
// Provider dispatcher: openai-chat reuses chatCompletion with the provider's
|
|
1172
|
-
// error label; anthropic/gemini go through their adapters.
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1390
|
+
// error label; anthropic/gemini go through their adapters. Effort rides
|
|
1391
|
+
// every kind: reasoning_effort on openai-chat (all providers, all models),
|
|
1392
|
+
// thinking budgets on anthropic-messages, thinkingLevel on gemini-generate.
|
|
1393
|
+
// Auto omits the knob; a model that truly lacks it 400s and the transports
|
|
1394
|
+
// above retry once without it.
|
|
1175
1395
|
export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
|
|
1176
1396
|
const def = getProvider(provider);
|
|
1177
1397
|
if (!def)
|
|
@@ -1188,7 +1408,14 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1188
1408
|
const endpoint = provider === "opencode-zen"
|
|
1189
1409
|
? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
|
|
1190
1410
|
: chatEndpointFor(provider, opts?.baseURL);
|
|
1191
|
-
|
|
1411
|
+
// Effort passes through for every openai-chat provider (zen, OpenAI,
|
|
1412
|
+
// DeepSeek, Mistral, Kilo, openai-compatible, local runtimes): the shared
|
|
1413
|
+
// transport sends reasoning_effort when non-Auto and falls back without it
|
|
1414
|
+
// on a server rejection. Anthropic/Gemini kinds receive opts directly
|
|
1415
|
+
// above and map effort to their native thinking knobs.
|
|
1416
|
+
const effortOpts = opts?.reasoningEffort !== undefined
|
|
1417
|
+
? { reasoningEffort: opts.reasoningEffort }
|
|
1418
|
+
: {};
|
|
1192
1419
|
const chatOpts = {
|
|
1193
1420
|
onToken: opts?.onToken,
|
|
1194
1421
|
onPhase: opts?.onPhase,
|
|
@@ -1197,6 +1424,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1197
1424
|
onThinking: opts?.onThinking,
|
|
1198
1425
|
sleep: opts?.sleep,
|
|
1199
1426
|
signal: opts?.signal,
|
|
1427
|
+
// Ticket 08: provider-hook attribution for the shared openai-chat
|
|
1428
|
+
// transport (otherwise every kind would report "opencode-zen").
|
|
1429
|
+
providerId: provider,
|
|
1200
1430
|
...effortOpts,
|
|
1201
1431
|
// Compaction path only (undefined for the normal loop → tools sent).
|
|
1202
1432
|
...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
|
|
@@ -1205,9 +1435,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1205
1435
|
: {}),
|
|
1206
1436
|
};
|
|
1207
1437
|
// Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
|
|
1208
|
-
// retry) with its registry
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1438
|
+
// retry, effort with server-rejection fallback) with its registry
|
|
1439
|
+
// endpoint + label; HTTP failures are reframed into concise actionable
|
|
1440
|
+
// Kilo errors (see src/kilo.ts).
|
|
1211
1441
|
if (provider === "kilo") {
|
|
1212
1442
|
try {
|
|
1213
1443
|
return await chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
|