@bcmyguest/openmem 0.6.3 → 0.9.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/{skills → .agents/skills}/openmem/SKILL.md +5 -0
- package/README.md +81 -76
- package/cli/coding-agent/capabilities.mjs +14 -3
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +899 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +2 -1
- package/cli/coding-agent/hook-runner.mjs +28 -4
- package/cli/coding-agent/install-access.mjs +43 -17
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +119 -15
- package/cli/coding-agent/opencode-config.mjs +21 -5
- package/cli/coding-agent/opencode-plugin.mjs +39 -3
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +2 -1
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +30 -6
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +18 -2
- package/cli/install-coding-agent.mjs +173 -40
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -0
- package/cli/mcp/knowledge.mjs +333 -0
- package/cli/mcp/memory-facade.mjs +99 -33
- package/cli/openmem-mcp.mjs +3 -0
- package/cli/openmem.mjs +18 -1
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +2 -0
- package/cli/switch-memory-provider.mjs +1 -1
- package/compose.yaml +67 -4
- package/config/hindsight.env.example +2 -0
- package/config/openmem.toml.example +64 -2
- package/docs/architecture.md +35 -9
- package/docs/coding-agent-hooks.md +26 -2
- package/docs/coding-agents.md +27 -7
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +30 -7
- package/docs/context-management.md +230 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +226 -0
- package/docs/context-store.md +277 -0
- package/docs/evidence-plan.md +216 -0
- package/docs/hindsight.md +5 -2
- package/docs/mcp-setup.md +5 -2
- package/docs/mem0.md +39 -1
- package/docs/memory-providers.md +2 -2
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +22 -0
- package/docs/npx-install.md +6 -4
- package/docs/openmem-config.md +2 -2
- package/docs/quickstart.md +12 -6
- package/docs/setup-wizard.md +5 -4
- package/docs/sources-feeds.md +9 -22
- package/package.json +5 -4
- package/providers/cognee/OPERATOR.md +2 -2
- package/providers/hindsight/lib/clack.mjs +122 -20
- package/providers/hindsight/lib/coding-agent.mjs +1 -1
- package/providers/hindsight/lib/config.mjs +6 -0
- package/providers/hindsight/lib/env-setup.mjs +2 -1
- package/providers/hindsight/lib/litellm-routes.mjs +21 -12
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +38 -9
- package/providers/hindsight/lib/probe.mjs +21 -0
- package/providers/hindsight/lib/stack-env.mjs +5 -12
- package/providers/hindsight/lib/state.mjs +6 -1
- package/providers/hindsight/lib/summary.mjs +16 -2
- package/providers/hindsight/lib/validate.mjs +6 -0
- package/providers/hindsight/wizard.mjs +3 -2
- package/providers/mem0/OPERATOR.md +21 -2
- package/providers/mem0/compose.yaml +24 -2
- package/providers/mem0/lib/coding-agent.mjs +1 -1
- package/providers/mem0/lib/configure.mjs +66 -0
- package/providers/mem0/lib/litellm.mjs +457 -0
- package/providers/mem0/lib/stack-env.mjs +40 -1
- package/providers/mem0/lib/summary.mjs +30 -2
- package/providers/mem0/manifest.json +1 -1
- package/providers/mem0/wizard.mjs +15 -2
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +608 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +2 -1
- package/scripts/run-setup.sh +14 -5
- package/scripts/setup.mjs +14 -1
- package/server.json +2 -2
- package/wizard/lib/context-step.mjs +140 -0
- package/wizard/lib/litellm-env.mjs +55 -0
- package/wizard/lib/pm-config.mjs +212 -4
- package/wizard/lib/prompts.mjs +28 -0
- package/wizard/lib/run-wizard.mjs +26 -31
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** mem0's contribution to the wizard's closing summary. */
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readKey } from "./env-file.mjs";
|
|
4
|
+
import { LLM_ALIAS, litellmEnvFile, routesFile } from "./litellm.mjs";
|
|
5
|
+
import { MERIDIAN_TERMS_URL } from "../../hindsight/lib/meridian.mjs";
|
|
4
6
|
import { resolveEnvFile, ensureState } from "./state.mjs";
|
|
5
7
|
import { containerUrl, displayUrl, resolveApiUrl, urlProblem } from "./url.mjs";
|
|
6
8
|
|
|
@@ -30,9 +32,27 @@ export function summary(ctx, h) {
|
|
|
30
32
|
` ${path.join(ctx.repoRoot, ".env")} (MEM0_API_URL, MEM0_INDEXER_URL; no secrets)`,
|
|
31
33
|
];
|
|
32
34
|
|
|
35
|
+
// The stack LiteLLM option: this run's answer, else what the last configure run saved (`--up`, `--down`).
|
|
36
|
+
const saved = (k) => (ctx.wizardConf ? h.envValue(ctx.wizardConf, k) : "");
|
|
37
|
+
const litellm =
|
|
38
|
+
ctx.mem0?.litellm !== undefined
|
|
39
|
+
? ctx.mem0.litellm
|
|
40
|
+
: saved("MEM0_LITELLM") === "1"
|
|
41
|
+
? { backend: saved("MEM0_LITELLM_BACKEND"), model: saved("MEM0_LITELLM_MODEL"), port: saved("MEM0_LITELLM_PORT") || "4000" }
|
|
42
|
+
: null;
|
|
43
|
+
/** @type {string[]} */
|
|
44
|
+
const logServices = ["indexer"];
|
|
45
|
+
if (litellm) {
|
|
46
|
+
running.push(
|
|
47
|
+
` LiteLLM (mem0) http://127.0.0.1:${litellm.port}/v1 ${LLM_ALIAS} -> ${litellm.backend} ${litellm.model}; loopback only, master key required`,
|
|
48
|
+
);
|
|
49
|
+
configFiles.push(` ${routesFile(ctx.home)} (mem0 LiteLLM routes)`, ` ${litellmEnvFile(ctx.home)} (LITELLM_MASTER_KEY and provider keys, mode 600)`);
|
|
50
|
+
logServices.push("litellm");
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
/** @type {string[]} */
|
|
34
54
|
const notes = [];
|
|
35
|
-
if (ctx.action === "down") return { running, configFiles, notes, logServices
|
|
55
|
+
if (ctx.action === "down") return { running, configFiles, notes, logServices };
|
|
36
56
|
let loopback = false;
|
|
37
57
|
if (valid) {
|
|
38
58
|
const parsed = new URL(raw);
|
|
@@ -56,5 +76,13 @@ export function summary(ctx, h) {
|
|
|
56
76
|
? "The indexer container reaches a loopback server as host.docker.internal; on Linux a server bound only to 127.0.0.1 is not reachable that way: bind it to 0.0.0.0 or the docker bridge IP, or use a hostname in MEM0_API_URL."
|
|
57
77
|
: "The indexer container must be able to reach the server at the URL above (it uses the same host name).",
|
|
58
78
|
);
|
|
59
|
-
|
|
79
|
+
if (litellm) {
|
|
80
|
+
notes.push(
|
|
81
|
+
`mem0's LLM: point your mem0 config at http://127.0.0.1:${litellm.port}/v1 (provider openai, model ${LLM_ALIAS}, api_key = LITELLM_MASTER_KEY); ./setup printed the blocks. See docs/mem0.md.`,
|
|
82
|
+
);
|
|
83
|
+
if (litellm.backend === "meridian") {
|
|
84
|
+
notes.push(`Claude subscription use through Meridian is governed by Anthropic's Consumer Terms: ${MERIDIAN_TERMS_URL}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { running, configFiles, notes, logServices };
|
|
60
88
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"settings": {
|
|
7
7
|
"nativeDocs": "providers/mem0/OPERATOR.md (stack) and https://docs.mem0.ai/open-source/features/rest-api (upstream)",
|
|
8
8
|
"nativeConfigPaths": ["~/.config/personal-memory/mem0.env"],
|
|
9
|
-
"wizardOwns": ["indexer URL and the host-gateway rewrite", "API key file for the indexer (mode 600)", "index.yml sources (shared indexer)"]
|
|
9
|
+
"wizardOwns": ["indexer URL and the host-gateway rewrite", "API key file for the indexer (mode 600)", "index.yml sources (shared indexer)", "optional: stack LiteLLM routes for mem0's LLM on 127.0.0.1 (compose profile litellm); you apply the printed mem0 config"]
|
|
10
10
|
},
|
|
11
11
|
"compose": {
|
|
12
12
|
"profile": "mem0",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { applyConfig, configToml as buildConfigToml } from "./lib/config.mjs";
|
|
11
11
|
import { setupCodingAgent as printCodingAgentPaths } from "./lib/coding-agent.mjs";
|
|
12
12
|
import { clackSetup as askInClack, configureUrlAndKey } from "./lib/configure.mjs";
|
|
13
|
+
import { applyProfile, captureAnthropicKey, configureLitellm, stackEnvFor } from "./lib/litellm.mjs";
|
|
13
14
|
import { captureKeyFromEnv } from "./lib/secret.mjs";
|
|
14
15
|
import { smoke as mem0Smoke } from "./lib/smoke.mjs";
|
|
15
16
|
import { ensureState } from "./lib/state.mjs";
|
|
@@ -30,6 +31,7 @@ export { prepareStackEnv, setupNotices } from "./lib/stack-env.mjs";
|
|
|
30
31
|
*/
|
|
31
32
|
export async function init(ctx) {
|
|
32
33
|
captureKeyFromEnv();
|
|
34
|
+
captureAnthropicKey();
|
|
33
35
|
ensureState(ctx);
|
|
34
36
|
applyConfig(ctx);
|
|
35
37
|
}
|
|
@@ -43,6 +45,8 @@ export async function hydrate(ctx, h) {
|
|
|
43
45
|
const state = ensureState(ctx);
|
|
44
46
|
applyConfig(ctx);
|
|
45
47
|
state.savedUrl = (h.envValue(ctx.wizardConf, "MEM0_API_URL") || "").trim();
|
|
48
|
+
// `--up`: the stack LiteLLM option saved by the last configure run.
|
|
49
|
+
applyProfile(ctx, h.envValue(ctx.wizardConf, "MEM0_LITELLM") === "1");
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -63,12 +67,21 @@ export function apiBase(ctx, h) {
|
|
|
63
67
|
export const resolvePorts = resolveMem0Url;
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
|
-
* Announced step "URL, key and login": server URL, API key into the mode 600 env file
|
|
70
|
+
* Announced step "URL, key and login": server URL, API key into the mode 600 env file, then the
|
|
71
|
+
* optional "route mem0's LLM through the stack LiteLLM" (lib/litellm.mjs).
|
|
67
72
|
* @param {Record<string, any>} ctx
|
|
68
73
|
* @param {Helpers} h
|
|
69
74
|
*/
|
|
70
75
|
export async function configureModelRouting(ctx, h) {
|
|
71
76
|
await configureUrlAndKey(ctx, h);
|
|
77
|
+
const state = ensureState(ctx);
|
|
78
|
+
if (!state.url) return; // invalid URL: a dry run continues without the option
|
|
79
|
+
const keys = await configureLitellm(ctx, h);
|
|
80
|
+
applyProfile(ctx, Boolean(keys));
|
|
81
|
+
if (!keys) return;
|
|
82
|
+
// configureUrlAndKey saved MEM0_API_URL alone; save again with the option's keys (wizard.conf is rewritten whole).
|
|
83
|
+
if (ctx.dry) h.info(`[dry-run] record ${Object.keys(keys).join(", ")} in ${ctx.wizardConf}`);
|
|
84
|
+
else h.saveWizardConf(ctx, { MEM0_API_URL: state.url, ...keys }, { pmSections: buildConfigToml(ctx, state.url) });
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
/**
|
|
@@ -81,7 +94,7 @@ export function stackEnv(ctx, h) {
|
|
|
81
94
|
return {
|
|
82
95
|
description:
|
|
83
96
|
"scripts/setup.mjs: write .env (MEM0_API_URL, MEM0_INDEXER_URL, no secrets), compose.override.yaml",
|
|
84
|
-
env: { MEM0_API_URL: resolveApiUrl(ctx, h) },
|
|
97
|
+
env: { MEM0_API_URL: resolveApiUrl(ctx, h), ...stackEnvFor(ctx) },
|
|
85
98
|
};
|
|
86
99
|
}
|
|
87
100
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# LiteLLM config for the OpenMem context proxy (compose service context-proxy). See docs/context-proxy.md.
|
|
2
|
+
#
|
|
3
|
+
# Claude Code: requests on /v1/messages go to Anthropic. With no ANTHROPIC_API_KEY in the proxy environment, the
|
|
4
|
+
# client's own credentials (Claude subscription OAuth or API key) pass through; the client sends the LiteLLM key in
|
|
5
|
+
# the x-litellm-api-key header.
|
|
6
|
+
#
|
|
7
|
+
# OpenAI-format clients (/v1/chat/completions, /v1/responses) need explicit routes; the "*" catch-all below sends
|
|
8
|
+
# every other model name to Anthropic. Add one entry per model, for example:
|
|
9
|
+
#
|
|
10
|
+
# - model_name: gpt-5
|
|
11
|
+
# litellm_params:
|
|
12
|
+
# model: openai/gpt-5
|
|
13
|
+
# api_key: os.environ/OPENAI_API_KEY
|
|
14
|
+
#
|
|
15
|
+
# and put OPENAI_API_KEY in the env file (CONTEXT_PROXY_ENV_FILE).
|
|
16
|
+
#
|
|
17
|
+
# Codex with ChatGPT login needs no route: LiteLLM's built-in /backend-api/codex/{models,responses} pass-through
|
|
18
|
+
# (PR #41522, in the pinned dev image) forwards the ChatGPT bearer to chatgpt.com; the client sends the LiteLLM key
|
|
19
|
+
# in x-litellm-api-key. The hook sees those calls as call type pass_through_endpoint.
|
|
20
|
+
model_list:
|
|
21
|
+
- model_name: "anthropic/*"
|
|
22
|
+
litellm_params:
|
|
23
|
+
model: "anthropic/*"
|
|
24
|
+
- model_name: "*"
|
|
25
|
+
litellm_params:
|
|
26
|
+
model: "anthropic/*"
|
|
27
|
+
|
|
28
|
+
litellm_settings:
|
|
29
|
+
# Resolved relative to this file: proxy/openmem_context_hook.py
|
|
30
|
+
callbacks: openmem_context_hook.handler
|
|
31
|
+
|
|
32
|
+
general_settings:
|
|
33
|
+
master_key: os.environ/LITELLM_MASTER_KEY
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Request-format adapters: Anthropic Messages, OpenAI Chat Completions, OpenAI Responses.
|
|
2
|
+
|
|
3
|
+
Every adapter works on the list of conversation items (messages or Responses input items) and never mutates
|
|
4
|
+
its inputs: methods that change an item return a shallow copy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .compress import compress_text, compressed_blob_id
|
|
11
|
+
|
|
12
|
+
_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.DOTALL)
|
|
13
|
+
_RESULT_TYPES = ("function_call_output", "custom_tool_call_output", "local_shell_call_output")
|
|
14
|
+
_CALL_TYPES = ("function_call", "custom_tool_call", "local_shell_call")
|
|
15
|
+
# LiteLLM's ChatGPT-subscription pass-through (PR #41522): Codex posts Responses bodies here, and the pre-call hook
|
|
16
|
+
# sees them with call type "pass_through_endpoint".
|
|
17
|
+
CODEX_RESPONSES_ROUTE = "/backend-api/codex/responses"
|
|
18
|
+
# Anthropic thinking blocks: each carries a signature over the conversation before it, so an edited prefix voids it.
|
|
19
|
+
THINKING_TYPES = ("thinking", "redacted_thinking")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cache_markers(item):
|
|
23
|
+
"""cache_control markers on a message and its content blocks, tool_result inner blocks included."""
|
|
24
|
+
count = 1 if item.get("cache_control") else 0
|
|
25
|
+
content = item.get("content")
|
|
26
|
+
for b in content if isinstance(content, list) else ():
|
|
27
|
+
if isinstance(b, dict):
|
|
28
|
+
count += _cache_markers(b)
|
|
29
|
+
return count
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnsupportedCallType(LookupError):
|
|
33
|
+
"""No adapter handles this LiteLLM call type."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compact_json(value):
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
try:
|
|
39
|
+
value = json.loads(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return value
|
|
42
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parts_text(parts, text_types):
|
|
46
|
+
"""Join text parts; None when a non-text part (image, file) is present."""
|
|
47
|
+
texts = []
|
|
48
|
+
for part in parts:
|
|
49
|
+
if not isinstance(part, dict) or part.get("type") not in text_types:
|
|
50
|
+
return None
|
|
51
|
+
texts.append(str(part.get("text", "")))
|
|
52
|
+
return "\n".join(texts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _plain_text(content, text_types):
|
|
56
|
+
if isinstance(content, str):
|
|
57
|
+
return content
|
|
58
|
+
if isinstance(content, list):
|
|
59
|
+
out = []
|
|
60
|
+
for part in content:
|
|
61
|
+
if isinstance(part, dict) and part.get("type") in text_types:
|
|
62
|
+
out.append(str(part.get("text", "")))
|
|
63
|
+
elif isinstance(part, dict):
|
|
64
|
+
out.append(f"[{part.get('type', 'part')}]")
|
|
65
|
+
return "\n".join(out)
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _log_result_text(text):
|
|
70
|
+
"""Log text for a tool result; compressed results reference their blob."""
|
|
71
|
+
bid = compressed_blob_id(text)
|
|
72
|
+
if bid is None:
|
|
73
|
+
return text
|
|
74
|
+
return f"[blob:{bid}]\n" + text.split("\n", 1)[1] if "\n" in text else f"[blob:{bid}]"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _compress_content(content, over_tokens, text_type):
|
|
78
|
+
"""Compressed replacement for a tool-result content value, or None. Returns (new_content, bid, text)."""
|
|
79
|
+
if isinstance(content, str):
|
|
80
|
+
text = content
|
|
81
|
+
res = compress_text(text, over_tokens)
|
|
82
|
+
if res is None:
|
|
83
|
+
return None
|
|
84
|
+
return res[0], res[1], text
|
|
85
|
+
if isinstance(content, list) and content:
|
|
86
|
+
text = _parts_text(content, (text_type, "text"))
|
|
87
|
+
if text is None:
|
|
88
|
+
return None
|
|
89
|
+
res = compress_text(text, over_tokens)
|
|
90
|
+
if res is None:
|
|
91
|
+
return None
|
|
92
|
+
block = {"type": content[-1].get("type", text_type), "text": res[0]}
|
|
93
|
+
if "cache_control" in content[-1]:
|
|
94
|
+
block["cache_control"] = content[-1]["cache_control"]
|
|
95
|
+
return [block], res[1], text
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _without_thinking_blocks(content):
|
|
100
|
+
"""Content with its thinking blocks removed, or the same object when it has none."""
|
|
101
|
+
if not isinstance(content, list):
|
|
102
|
+
return content
|
|
103
|
+
kept = [b for b in content if not (isinstance(b, dict) and b.get("type") in THINKING_TYPES)]
|
|
104
|
+
return content if len(kept) == len(content) else kept
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _litellm_thinking(encrypted):
|
|
108
|
+
"""Whether a Responses reasoning item's encrypted_content is LiteLLM's encoding of signed Anthropic thinking
|
|
109
|
+
blocks (a JSON list holding a thinking block with a signature or a redacted_thinking block with data)."""
|
|
110
|
+
if not isinstance(encrypted, str) or not encrypted.strip().startswith("["):
|
|
111
|
+
return False
|
|
112
|
+
try:
|
|
113
|
+
blocks = json.loads(encrypted)
|
|
114
|
+
except ValueError:
|
|
115
|
+
return False
|
|
116
|
+
return isinstance(blocks, list) and any(
|
|
117
|
+
isinstance(b, dict) and ((b.get("type") == "thinking" and b.get("signature"))
|
|
118
|
+
or (b.get("type") == "redacted_thinking" and b.get("data")))
|
|
119
|
+
for b in blocks)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def strip_reminders(text):
|
|
123
|
+
return _REMINDER_RE.sub("", text).strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AnthropicAdapter:
|
|
127
|
+
name = "anthropic"
|
|
128
|
+
items_key = "messages"
|
|
129
|
+
context_keys = ("system", "tools")
|
|
130
|
+
text_type = "text"
|
|
131
|
+
|
|
132
|
+
def get_items(self, data):
|
|
133
|
+
return data.get("messages")
|
|
134
|
+
|
|
135
|
+
def is_pinned(self, item):
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def _blocks(self, item):
|
|
139
|
+
content = item.get("content")
|
|
140
|
+
return content if isinstance(content, list) else None
|
|
141
|
+
|
|
142
|
+
def is_turn_start(self, item):
|
|
143
|
+
if item.get("role") != "user":
|
|
144
|
+
return False
|
|
145
|
+
content = item.get("content")
|
|
146
|
+
if isinstance(content, str):
|
|
147
|
+
return bool(content)
|
|
148
|
+
if isinstance(content, list) and content:
|
|
149
|
+
return not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content)
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def is_first_user(self, item):
|
|
153
|
+
"""A user message that is not only tool results (the task statement, when it is the first)."""
|
|
154
|
+
if item.get("role") != "user":
|
|
155
|
+
return False
|
|
156
|
+
content = item.get("content")
|
|
157
|
+
if isinstance(content, str):
|
|
158
|
+
return bool(content)
|
|
159
|
+
if isinstance(content, list):
|
|
160
|
+
return any(not (isinstance(b, dict) and b.get("type") == "tool_result") for b in content)
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def call_ids(self, item):
|
|
164
|
+
return {b.get("id") for b in self._blocks(item) or () if isinstance(b, dict) and b.get("type") in ("tool_use", "server_tool_use")}
|
|
165
|
+
|
|
166
|
+
def result_ids(self, item):
|
|
167
|
+
return {b.get("tool_use_id") for b in self._blocks(item) or () if isinstance(b, dict) and b.get("type") == "tool_result"}
|
|
168
|
+
|
|
169
|
+
def is_reply(self, item):
|
|
170
|
+
"""An agent message that calls no tool (the answer that closes a turn)."""
|
|
171
|
+
return item.get("role") == "assistant" and not self.call_ids(item)
|
|
172
|
+
|
|
173
|
+
def is_step_start(self, items, j):
|
|
174
|
+
"""Whether items[j] opens one tool exchange of a turn (where an in-turn cut may resume)."""
|
|
175
|
+
return items[j].get("role") == "assistant"
|
|
176
|
+
|
|
177
|
+
def is_assistant(self, item):
|
|
178
|
+
return item.get("role") == "assistant"
|
|
179
|
+
|
|
180
|
+
def without_reasoning(self, item):
|
|
181
|
+
"""The assistant message without thinking blocks: the same object when it has none, None when nothing
|
|
182
|
+
else is left (the API merges the neighbours of a dropped message, and no tool_use goes with it)."""
|
|
183
|
+
content = _without_thinking_blocks(item.get("content"))
|
|
184
|
+
if content is item.get("content"):
|
|
185
|
+
return item
|
|
186
|
+
return {**item, "content": content} if content else None
|
|
187
|
+
|
|
188
|
+
def tool_names(self, items):
|
|
189
|
+
names = {}
|
|
190
|
+
for item in items:
|
|
191
|
+
for b in self._blocks(item) or ():
|
|
192
|
+
if isinstance(b, dict) and b.get("type") in ("tool_use", "server_tool_use"):
|
|
193
|
+
names[b.get("id")] = b.get("name")
|
|
194
|
+
return names
|
|
195
|
+
|
|
196
|
+
def system_text(self, data):
|
|
197
|
+
return _plain_text(data.get("system", ""), ("text",))
|
|
198
|
+
|
|
199
|
+
def cache_breakpoints(self, data):
|
|
200
|
+
"""(count, positions) of the request's cache_control markers: "tools", "system" or "m-<k>" (the k-th message
|
|
201
|
+
from the end, 1 = last), in prefix order. A tool_result's inner blocks count for their message."""
|
|
202
|
+
at = []
|
|
203
|
+
for key in ("tools", "system"):
|
|
204
|
+
value = data.get(key)
|
|
205
|
+
at += [key] * sum(1 for b in value if isinstance(b, dict) and b.get("cache_control")) \
|
|
206
|
+
if isinstance(value, list) else []
|
|
207
|
+
messages = data.get("messages")
|
|
208
|
+
messages = messages if isinstance(messages, list) else []
|
|
209
|
+
for j, item in enumerate(messages):
|
|
210
|
+
if isinstance(item, dict):
|
|
211
|
+
at += [f"m-{len(messages) - j}"] * _cache_markers(item)
|
|
212
|
+
return len(at), ",".join(at)
|
|
213
|
+
|
|
214
|
+
def add_instruction(self, data, text):
|
|
215
|
+
"""Append one text block to `system`; a string system becomes its own leading block."""
|
|
216
|
+
system = data.get("system")
|
|
217
|
+
if isinstance(system, list):
|
|
218
|
+
blocks = list(system)
|
|
219
|
+
elif isinstance(system, str) and system:
|
|
220
|
+
blocks = [{"type": "text", "text": system}]
|
|
221
|
+
else:
|
|
222
|
+
blocks = []
|
|
223
|
+
data["system"] = [*blocks, {"type": "text", "text": text}]
|
|
224
|
+
|
|
225
|
+
def user_text(self, item):
|
|
226
|
+
return _plain_text(item.get("content", ""), ("text",))
|
|
227
|
+
|
|
228
|
+
def compress_item(self, item, over_tokens, on_blob):
|
|
229
|
+
if item.get("role") != "user":
|
|
230
|
+
return item
|
|
231
|
+
blocks = self._blocks(item)
|
|
232
|
+
if not blocks:
|
|
233
|
+
return item
|
|
234
|
+
new_blocks = None
|
|
235
|
+
for i, b in enumerate(blocks):
|
|
236
|
+
if not (isinstance(b, dict) and b.get("type") == "tool_result"):
|
|
237
|
+
continue
|
|
238
|
+
res = _compress_content(b.get("content"), over_tokens, "text")
|
|
239
|
+
if res is None:
|
|
240
|
+
continue
|
|
241
|
+
on_blob(res[1], res[2])
|
|
242
|
+
if new_blocks is None:
|
|
243
|
+
new_blocks = list(blocks)
|
|
244
|
+
new_blocks[i] = {**b, "content": res[0]}
|
|
245
|
+
return item if new_blocks is None else {**item, "content": new_blocks}
|
|
246
|
+
|
|
247
|
+
def prepend_note(self, item, note):
|
|
248
|
+
# Always list form: Claude Code sends the same turn as a text block first and as a string later, and both
|
|
249
|
+
# must render to the same blocks (cache_control aside) to keep the prompt-cache prefix.
|
|
250
|
+
content = item.get("content")
|
|
251
|
+
blocks = [{"type": "text", "text": content}] if isinstance(content, str) else list(content)
|
|
252
|
+
return {**item, "content": [{"type": "text", "text": note}, *blocks]}
|
|
253
|
+
|
|
254
|
+
def rows(self, item, names):
|
|
255
|
+
role = item.get("role", "user")
|
|
256
|
+
content = item.get("content")
|
|
257
|
+
if isinstance(content, str):
|
|
258
|
+
return [(role, "text", None, content)]
|
|
259
|
+
out = []
|
|
260
|
+
for b in content or ():
|
|
261
|
+
if not isinstance(b, dict):
|
|
262
|
+
continue
|
|
263
|
+
t = b.get("type")
|
|
264
|
+
if t == "text":
|
|
265
|
+
out.append((role, "text", None, str(b.get("text", ""))))
|
|
266
|
+
elif t in ("tool_use", "server_tool_use"):
|
|
267
|
+
out.append(("assistant", "tool_use", b.get("name"), _compact_json(b.get("input", {}))))
|
|
268
|
+
elif t == "tool_result":
|
|
269
|
+
text = _plain_text(b.get("content", ""), ("text",))
|
|
270
|
+
out.append(("tool", "tool_result", names.get(b.get("tool_use_id")), _log_result_text(text)))
|
|
271
|
+
elif t == "thinking":
|
|
272
|
+
if b.get("thinking"):
|
|
273
|
+
out.append(("assistant", "thinking", None, str(b["thinking"])))
|
|
274
|
+
elif t == "redacted_thinking":
|
|
275
|
+
continue
|
|
276
|
+
else:
|
|
277
|
+
out.append((role, "text", None, f"[{t}]"))
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class ChatAdapter:
|
|
282
|
+
name = "chat"
|
|
283
|
+
items_key = "messages"
|
|
284
|
+
context_keys = ("tools", "functions")
|
|
285
|
+
text_type = "text"
|
|
286
|
+
|
|
287
|
+
def get_items(self, data):
|
|
288
|
+
return data.get("messages")
|
|
289
|
+
|
|
290
|
+
def is_pinned(self, item):
|
|
291
|
+
return item.get("role") in ("system", "developer")
|
|
292
|
+
|
|
293
|
+
def is_turn_start(self, item):
|
|
294
|
+
return item.get("role") == "user"
|
|
295
|
+
|
|
296
|
+
def is_first_user(self, item):
|
|
297
|
+
return item.get("role") == "user"
|
|
298
|
+
|
|
299
|
+
def call_ids(self, item):
|
|
300
|
+
return {c.get("id") for c in item.get("tool_calls") or () if isinstance(c, dict)}
|
|
301
|
+
|
|
302
|
+
def result_ids(self, item):
|
|
303
|
+
return {item.get("tool_call_id")} if item.get("role") == "tool" else set()
|
|
304
|
+
|
|
305
|
+
def is_reply(self, item):
|
|
306
|
+
return item.get("role") == "assistant" and not item.get("tool_calls")
|
|
307
|
+
|
|
308
|
+
def is_step_start(self, items, j):
|
|
309
|
+
return items[j].get("role") == "assistant"
|
|
310
|
+
|
|
311
|
+
def is_assistant(self, item):
|
|
312
|
+
return item.get("role") == "assistant"
|
|
313
|
+
|
|
314
|
+
def without_reasoning(self, item):
|
|
315
|
+
"""The assistant message without LiteLLM's signed thinking: the `thinking_blocks` field and thinking
|
|
316
|
+
parts of list content. With `thinking_blocks`, `reasoning_content` is LiteLLM's text copy of the same
|
|
317
|
+
thinking (never replayed to Anthropic) and goes too, as the OpenCode 2 port drops the whole reasoning part;
|
|
318
|
+
`reasoning_content` alone is unsigned and stays. None when no content, tool call or function call is left."""
|
|
319
|
+
content = _without_thinking_blocks(item.get("content"))
|
|
320
|
+
if content is item.get("content") and "thinking_blocks" not in item:
|
|
321
|
+
return item
|
|
322
|
+
drop = ("thinking_blocks", "reasoning_content") if "thinking_blocks" in item else ()
|
|
323
|
+
out = {k: v for k, v in item.items() if k not in drop}
|
|
324
|
+
out["content"] = content
|
|
325
|
+
if not (content or out.get("tool_calls") or out.get("function_call")):
|
|
326
|
+
return None
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
def tool_names(self, items):
|
|
330
|
+
names = {}
|
|
331
|
+
for item in items:
|
|
332
|
+
for c in item.get("tool_calls") or ():
|
|
333
|
+
if isinstance(c, dict):
|
|
334
|
+
names[c.get("id")] = (c.get("function") or {}).get("name")
|
|
335
|
+
return names
|
|
336
|
+
|
|
337
|
+
def system_text(self, data):
|
|
338
|
+
msgs = data.get("messages") or []
|
|
339
|
+
return "\n".join(_plain_text(m.get("content", ""), ("text",)) for m in msgs if self.is_pinned(m))
|
|
340
|
+
|
|
341
|
+
def add_instruction(self, data, text):
|
|
342
|
+
"""Insert a system message after the leading system/developer messages."""
|
|
343
|
+
msgs = data["messages"]
|
|
344
|
+
at = next((i for i, m in enumerate(msgs) if not self.is_pinned(m)), len(msgs))
|
|
345
|
+
data["messages"] = [*msgs[:at], {"role": "system", "content": text}, *msgs[at:]]
|
|
346
|
+
|
|
347
|
+
def user_text(self, item):
|
|
348
|
+
return _plain_text(item.get("content", ""), ("text",))
|
|
349
|
+
|
|
350
|
+
def compress_item(self, item, over_tokens, on_blob):
|
|
351
|
+
if item.get("role") != "tool":
|
|
352
|
+
return item
|
|
353
|
+
res = _compress_content(item.get("content"), over_tokens, "text")
|
|
354
|
+
if res is None:
|
|
355
|
+
return item
|
|
356
|
+
on_blob(res[1], res[2])
|
|
357
|
+
return {**item, "content": res[0]}
|
|
358
|
+
|
|
359
|
+
def prepend_note(self, item, note):
|
|
360
|
+
content = item.get("content")
|
|
361
|
+
if isinstance(content, list):
|
|
362
|
+
return {**item, "content": [{"type": "text", "text": note}, *content]}
|
|
363
|
+
return {**item, "content": f"{note}\n\n{content or ''}"}
|
|
364
|
+
|
|
365
|
+
def rows(self, item, names):
|
|
366
|
+
role = item.get("role")
|
|
367
|
+
out = []
|
|
368
|
+
if role == "tool":
|
|
369
|
+
text = _plain_text(item.get("content", ""), ("text",))
|
|
370
|
+
name = item.get("name") or names.get(item.get("tool_call_id"))
|
|
371
|
+
return [("tool", "tool_result", name, _log_result_text(text))]
|
|
372
|
+
reasoning = item.get("reasoning_content")
|
|
373
|
+
if isinstance(reasoning, str) and reasoning:
|
|
374
|
+
out.append(("assistant", "thinking", None, reasoning))
|
|
375
|
+
text = _plain_text(item.get("content") or "", ("text",))
|
|
376
|
+
if text:
|
|
377
|
+
out.append(("assistant" if role == "assistant" else "user", "text", None, text))
|
|
378
|
+
for c in item.get("tool_calls") or ():
|
|
379
|
+
fn = (c or {}).get("function") or {}
|
|
380
|
+
out.append(("assistant", "tool_use", fn.get("name"), _compact_json(fn.get("arguments", "{}"))))
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class ResponsesAdapter:
|
|
385
|
+
name = "responses"
|
|
386
|
+
items_key = "input"
|
|
387
|
+
context_keys = ("instructions", "tools")
|
|
388
|
+
text_type = "input_text"
|
|
389
|
+
_TEXT_TYPES = ("input_text", "output_text", "text", "summary_text")
|
|
390
|
+
|
|
391
|
+
def get_items(self, data):
|
|
392
|
+
items = data.get("input")
|
|
393
|
+
return items if isinstance(items, list) else None
|
|
394
|
+
|
|
395
|
+
def _is_message(self, item):
|
|
396
|
+
return item.get("type", "message") == "message" and "role" in item
|
|
397
|
+
|
|
398
|
+
def is_pinned(self, item):
|
|
399
|
+
return self._is_message(item) and item.get("role") in ("system", "developer")
|
|
400
|
+
|
|
401
|
+
def is_turn_start(self, item):
|
|
402
|
+
return self._is_message(item) and item.get("role") == "user"
|
|
403
|
+
|
|
404
|
+
def is_first_user(self, item):
|
|
405
|
+
return self.is_turn_start(item)
|
|
406
|
+
|
|
407
|
+
def call_ids(self, item):
|
|
408
|
+
return {item.get("call_id")} if item.get("type") in _CALL_TYPES else set()
|
|
409
|
+
|
|
410
|
+
def result_ids(self, item):
|
|
411
|
+
return {item.get("call_id")} if item.get("type") in _RESULT_TYPES else set()
|
|
412
|
+
|
|
413
|
+
def is_reply(self, item):
|
|
414
|
+
return self._is_message(item) and item.get("role") == "assistant"
|
|
415
|
+
|
|
416
|
+
def is_step_start(self, items, j):
|
|
417
|
+
"""A reasoning item, or a call or agent message that follows neither reasoning nor another call, so a
|
|
418
|
+
reasoning item stays with its calls and parallel calls stay together."""
|
|
419
|
+
item = items[j]
|
|
420
|
+
if item.get("type") == "reasoning":
|
|
421
|
+
return True
|
|
422
|
+
if not (item.get("type") in _CALL_TYPES or self.is_reply(item)):
|
|
423
|
+
return False
|
|
424
|
+
prev = items[j - 1] if j > 0 else {}
|
|
425
|
+
return not (prev.get("type") == "reasoning" or prev.get("type") in _CALL_TYPES)
|
|
426
|
+
|
|
427
|
+
def is_assistant(self, item):
|
|
428
|
+
"""An item the model produced: reasoning, a tool call or an assistant message."""
|
|
429
|
+
return item.get("type") == "reasoning" or item.get("type") in _CALL_TYPES or self.is_reply(item)
|
|
430
|
+
|
|
431
|
+
def without_reasoning(self, item):
|
|
432
|
+
"""None for a reasoning item that carries LiteLLM-encoded Anthropic thinking (signed against the prefix);
|
|
433
|
+
every other item as is. OpenAI's own encrypted reasoning is not tied to the prefix and stays."""
|
|
434
|
+
if item.get("type") == "reasoning" and _litellm_thinking(item.get("encrypted_content")):
|
|
435
|
+
return None
|
|
436
|
+
return item
|
|
437
|
+
|
|
438
|
+
def tool_names(self, items):
|
|
439
|
+
return {i.get("call_id"): i.get("name", "shell" if i.get("type") == "local_shell_call" else None)
|
|
440
|
+
for i in items if i.get("type") in _CALL_TYPES}
|
|
441
|
+
|
|
442
|
+
def system_text(self, data):
|
|
443
|
+
parts = [str(data.get("instructions") or "")]
|
|
444
|
+
for item in data.get("input") or ():
|
|
445
|
+
if isinstance(item, dict) and self.is_pinned(item):
|
|
446
|
+
parts.append(_plain_text(item.get("content", ""), self._TEXT_TYPES))
|
|
447
|
+
return "\n".join(parts)
|
|
448
|
+
|
|
449
|
+
def add_instruction(self, data, text):
|
|
450
|
+
"""Append to `instructions`, which precedes every input item and stays fixed for a session."""
|
|
451
|
+
current = data.get("instructions")
|
|
452
|
+
data["instructions"] = f"{current}\n\n{text}" if isinstance(current, str) and current else text
|
|
453
|
+
|
|
454
|
+
def user_text(self, item):
|
|
455
|
+
return _plain_text(item.get("content", ""), self._TEXT_TYPES)
|
|
456
|
+
|
|
457
|
+
def compress_item(self, item, over_tokens, on_blob):
|
|
458
|
+
if item.get("type") not in _RESULT_TYPES:
|
|
459
|
+
return item
|
|
460
|
+
res = _compress_content(item.get("output"), over_tokens, "input_text")
|
|
461
|
+
if res is None:
|
|
462
|
+
return item
|
|
463
|
+
on_blob(res[1], res[2])
|
|
464
|
+
return {**item, "output": res[0]}
|
|
465
|
+
|
|
466
|
+
def prepend_note(self, item, note):
|
|
467
|
+
content = item.get("content")
|
|
468
|
+
if isinstance(content, list):
|
|
469
|
+
return {**item, "content": [{"type": "input_text", "text": note}, *content]}
|
|
470
|
+
return {**item, "content": f"{note}\n\n{content or ''}"}
|
|
471
|
+
|
|
472
|
+
def rows(self, item, names):
|
|
473
|
+
t = item.get("type", "message")
|
|
474
|
+
if self._is_message(item):
|
|
475
|
+
role = "assistant" if item.get("role") == "assistant" else "user"
|
|
476
|
+
return [(role, "text", None, _plain_text(item.get("content", ""), self._TEXT_TYPES))]
|
|
477
|
+
if t in _CALL_TYPES:
|
|
478
|
+
arg = item.get("arguments", item.get("input", item.get("action", {})))
|
|
479
|
+
return [("assistant", "tool_use", names.get(item.get("call_id")), _compact_json(arg))]
|
|
480
|
+
if t in _RESULT_TYPES:
|
|
481
|
+
text = _plain_text(item.get("output", ""), self._TEXT_TYPES)
|
|
482
|
+
return [("tool", "tool_result", names.get(item.get("call_id")), _log_result_text(text))]
|
|
483
|
+
if t == "reasoning":
|
|
484
|
+
summary = _plain_text(item.get("summary") or [], self._TEXT_TYPES)
|
|
485
|
+
return [("assistant", "thinking", None, summary)] if summary else []
|
|
486
|
+
return [("assistant", "text", None, f"[{t}]")]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def adapter_for(call_type, route=None):
|
|
490
|
+
"""Adapter for a LiteLLM call type; raises UnsupportedCallType for any other call.
|
|
491
|
+
|
|
492
|
+
A pass-through call is handled only on the Codex Responses route; every other pass-through body is opaque.
|
|
493
|
+
"""
|
|
494
|
+
name = str(getattr(call_type, "value", call_type))
|
|
495
|
+
if name == "pass_through_endpoint" and isinstance(route, str) and route.rstrip("/") == CODEX_RESPONSES_ROUTE:
|
|
496
|
+
return ResponsesAdapter()
|
|
497
|
+
match name:
|
|
498
|
+
case "anthropic_messages":
|
|
499
|
+
return AnthropicAdapter()
|
|
500
|
+
case "acompletion" | "completion":
|
|
501
|
+
return ChatAdapter()
|
|
502
|
+
case "aresponses" | "responses":
|
|
503
|
+
return ResponsesAdapter()
|
|
504
|
+
raise UnsupportedCallType(name)
|