@mupt-ai/dari-gateway 0.3.2 → 0.3.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 CHANGED
@@ -36,27 +36,26 @@ The gateway is the same runtime that serves Dari's hosted routing service. Choos
36
36
 
37
37
  ## Run
38
38
 
39
- Requires [Bun](https://bun.sh) 1.3 or later.
39
+ Requires [Bun](https://bun.sh) 1.3 or later. Write a `dari-gateway.yaml` (start from [`dari-gateway.example.yaml`](dari-gateway.example.yaml)), then:
40
40
 
41
41
  ```bash
42
- cp dari-gateway.example.yaml dari-gateway.yaml # edit routers and models
43
42
  export DARI_GATEWAY_KEYS="$(openssl rand -hex 24)" # keys your clients send
44
43
  export ANTHROPIC_API_KEY=... OPENAI_API_KEY=... # optional with subscriptions
45
- bun install
46
- bun run start
44
+ bunx @mupt-ai/dari-gateway
47
45
  ```
48
46
 
49
- Or with Docker, from this directory (in the monorepo, run `docker build -t dari-gateway dari-gateway` from the repository root):
47
+ Or with Docker:
50
48
 
51
49
  ```bash
52
- docker build -t dari-gateway .
53
50
  docker run -p 8080:8080 \
54
51
  -e DARI_GATEWAY_KEYS -e ANTHROPIC_API_KEY \
55
52
  -v "$PWD/dari-gateway.yaml:/config/dari-gateway.yaml" \
56
53
  -v dari-gateway-data:/data \
57
- dari-gateway
54
+ ghcr.io/mupt-ai/dari-gateway
58
55
  ```
59
56
 
57
+ From a clone, `bun install && bun run start` or `docker build -t dari-gateway .` run the same thing.
58
+
60
59
  | Variable | Meaning |
61
60
  | --- | --- |
62
61
  | `DARI_GATEWAY_KEYS` | Required. Comma-separated bearer keys clients authenticate with. |
@@ -59,7 +59,7 @@ export function interactionLooseChain(routerId, request) {
59
59
  // transcript step. Growing history and tool changes preserve identity, while
60
60
  // unrelated work reusing the same harness session gets a new group.
61
61
  export function interactionConversationPrefix(request) {
62
- const firstStep = conversationFingerprintSteps(looseFingerprintSteps(request.sourceProtocol, request.items)).at(0);
62
+ const firstStep = conversationFingerprintSteps(looseFingerprintSteps(request.sourceProtocol, request.items.filter((item) => !isOpenClawRuntimeContext(item)))).at(0);
63
63
  return sha256(stableStringify({
64
64
  version: CONVERSATION_PREFIX_VERSION,
65
65
  firstStep: firstStep === undefined
@@ -67,6 +67,15 @@ export function interactionConversationPrefix(request) {
67
67
  : canonicalInteractionFingerprintStep(firstStep),
68
68
  }));
69
69
  }
70
+ // OpenClaw appends a user message of runtime context (active exec sessions,
71
+ // subagents) to each request and drops it from later history. On a first
72
+ // turn it merges into the first transcript step, so leaving it in would give
73
+ // the first turn a different conversation than the turns after it.
74
+ const OPENCLAW_RUNTIME_CONTEXT = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
75
+ function isOpenClawRuntimeContext(item) {
76
+ return item.type === "message" && item.role === "user" && item.content.length > 0 &&
77
+ item.content.every((part) => part.type === "text" && part.text.trimStart().startsWith(OPENCLAW_RUNTIME_CONTEXT));
78
+ }
70
79
  export function interactionPrefixContext(routerId, request, targets) {
71
80
  const chainByModel = new Map();
72
81
  const itemIndexByHashByModel = new Map();
@@ -8,7 +8,7 @@ import { openAIResponsesToInteraction } from "../protocol/input/openai_responses
8
8
  import { parseJsonRequest } from "./json.js";
9
9
  import { museCodeClientId } from "./muse_code.js";
10
10
  import { handleRoutedCompletion } from "../runtime/routed_completion.js";
11
- import { codexConversationIdentity, codexRequestPurpose, codexRequestContext, } from "../runtime/harness_identity/index.js";
11
+ import { codexConversationIdentity, codexRequestPurpose, codexRequestContext, museCodeConversationIdentity, } from "../runtime/harness_identity/index.js";
12
12
  export async function handleResponses(request, routerId, selectorBackend) {
13
13
  try {
14
14
  const apiKey = bearerToken(request.headers);
@@ -23,14 +23,16 @@ export async function handleResponses(request, routerId, selectorBackend) {
23
23
  ...(agentContext === undefined ? {} : { agentContext }),
24
24
  ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
25
25
  };
26
- const harnessConversationIdentity = codexConversationIdentity(request.headers, rawPayload);
26
+ const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
27
27
  // Codex applies its global model_reasoning_effort setting to custom model
28
28
  // providers. That preference describes a direct model, not a heterogeneous
29
29
  // router, so let Dari select the reasoning effort for verified Codex turns.
30
30
  // Parsing above still validates malformed Responses reasoning controls.
31
- if (harnessConversationIdentity !== undefined && explicitModelId(payload.model) === null) {
31
+ if (codexIdentity !== undefined && explicitModelId(payload.model) === null) {
32
32
  payload.reasoning = undefined;
33
33
  }
34
+ const harnessConversationIdentity = codexIdentity
35
+ ?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
34
36
  return await handleRoutedCompletion({
35
37
  request,
36
38
  routerId,
@@ -13,6 +13,10 @@ export type HarnessConversationIdentity = {
13
13
  kind: "openai_chat_session";
14
14
  value: string;
15
15
  qualifiers?: string[];
16
+ } | {
17
+ kind: "muse_code_thread";
18
+ value: string;
19
+ qualifiers?: string[];
16
20
  };
17
21
  export type HarnessConversationScope = {
18
22
  organizationId: string;
@@ -6,4 +6,5 @@ export declare function openCodeRequestContext(headers: Headers): AgentRequestCo
6
6
  export declare function codexConversationIdentity(headers: Headers, payload: Record<string, unknown>): HarnessConversationIdentity | undefined;
7
7
  export declare function codexRequestPurpose(headers: Headers, payload: Record<string, unknown>): InteractionRequestPurpose | undefined;
8
8
  export declare function codexRequestContext(headers: Headers, payload: Record<string, unknown>): AgentRequestContext | undefined;
9
+ export declare function museCodeConversationIdentity(headers: Headers): HarnessConversationIdentity | undefined;
9
10
  export declare function claudeCodeConversationIdentity(headers: Headers, metadataSessionId?: string): HarnessConversationIdentity | undefined;
@@ -129,6 +129,13 @@ export function codexRequestContext(headers, payload) {
129
129
  agentType: boundedIdentity(agentKind, "x-codex-turn-metadata.subagent_kind"),
130
130
  };
131
131
  }
132
+ // Muse Code sends its thread on every Responses request. The main agent's
133
+ // thread is its session, and each subagent has its own thread, so each keeps
134
+ // its own conversation and lease.
135
+ export function museCodeConversationIdentity(headers) {
136
+ const threadId = boundedIdentity(headers.get("x-tbh-thread-id"), "x-tbh-thread-id");
137
+ return threadId === undefined ? undefined : { kind: "muse_code_thread", value: threadId };
138
+ }
132
139
  export function claudeCodeConversationIdentity(headers, metadataSessionId) {
133
140
  const candidates = [
134
141
  uuidIdentity(headers.get("x-claude-code-session-id"), "x-claude-code-session-id"),
@@ -1,2 +1,2 @@
1
1
  export { scopedHarnessAgentId, scopedHarnessConversationId, scopedHarnessSessionId, type HarnessConversationIdentity, type HarnessConversationScope, } from "./conversation.js";
2
- export { chatCompletionsConversationIdentity, claudeCodeConversationIdentity, codexConversationIdentity, codexRequestPurpose, codexRequestContext, openCodeRequestContext, piRequestContext, } from "./extract.js";
2
+ export { chatCompletionsConversationIdentity, claudeCodeConversationIdentity, codexConversationIdentity, codexRequestPurpose, codexRequestContext, museCodeConversationIdentity, openCodeRequestContext, piRequestContext, } from "./extract.js";
@@ -1,2 +1,2 @@
1
1
  export { scopedHarnessAgentId, scopedHarnessConversationId, scopedHarnessSessionId, } from "./conversation.js";
2
- export { chatCompletionsConversationIdentity, claudeCodeConversationIdentity, codexConversationIdentity, codexRequestPurpose, codexRequestContext, openCodeRequestContext, piRequestContext, } from "./extract.js";
2
+ export { chatCompletionsConversationIdentity, claudeCodeConversationIdentity, codexConversationIdentity, codexRequestPurpose, codexRequestContext, museCodeConversationIdentity, openCodeRequestContext, piRequestContext, } from "./extract.js";
@@ -5,7 +5,8 @@ const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*\/\S+$/;
5
5
  export async function loadGatewayConfig(path) {
6
6
  const file = Bun.file(path);
7
7
  if (!(await file.exists())) {
8
- throw new Error(`Gateway config not found at ${path}.`);
8
+ throw new Error(`Gateway config not found at ${path}. Write one from dari-gateway.example.yaml, `
9
+ + "or set DARI_GATEWAY_CONFIG (in the container image, mount it at /config/dari-gateway.yaml).");
9
10
  }
10
11
  const text = await file.text();
11
12
  const raw = path.endsWith(".json") ? JSON.parse(text) : Bun.YAML.parse(text);
@@ -106,6 +106,10 @@ export async function startSelfHostedGateway(env = process.env) {
106
106
  return { server, close: () => db.close() };
107
107
  }
108
108
  if (import.meta.main) {
109
- const { server, close } = await startSelfHostedGateway();
110
- installShutdownHandlers(server, async () => close());
109
+ // Startup errors are configuration mistakes: print the message, not a stack.
110
+ const started = await startSelfHostedGateway().catch((error) => {
111
+ console.error(`dari-gateway: ${error instanceof Error ? error.message : String(error)}`);
112
+ process.exit(1);
113
+ });
114
+ installShutdownHandlers(started.server, async () => started.close());
111
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mupt-ai/dari-gateway",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Self-hostable LLM gateway for coding agents and APIs: one endpoint for Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses, with subscription pooling, failover, and prompt-cache-aware routing.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -105,7 +105,10 @@ export function interactionLooseChain(routerId: string, request: InteractionRequ
105
105
  // unrelated work reusing the same harness session gets a new group.
106
106
  export function interactionConversationPrefix(request: InteractionRequest): string {
107
107
  const firstStep = conversationFingerprintSteps(
108
- looseFingerprintSteps(request.sourceProtocol, request.items),
108
+ looseFingerprintSteps(
109
+ request.sourceProtocol,
110
+ request.items.filter((item) => !isOpenClawRuntimeContext(item)),
111
+ ),
109
112
  ).at(0);
110
113
  return sha256(stableStringify({
111
114
  version: CONVERSATION_PREFIX_VERSION,
@@ -115,6 +118,17 @@ export function interactionConversationPrefix(request: InteractionRequest): stri
115
118
  }));
116
119
  }
117
120
 
121
+ // OpenClaw appends a user message of runtime context (active exec sessions,
122
+ // subagents) to each request and drops it from later history. On a first
123
+ // turn it merges into the first transcript step, so leaving it in would give
124
+ // the first turn a different conversation than the turns after it.
125
+ const OPENCLAW_RUNTIME_CONTEXT = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
126
+
127
+ function isOpenClawRuntimeContext(item: InteractionItem): boolean {
128
+ return item.type === "message" && item.role === "user" && item.content.length > 0 &&
129
+ item.content.every((part) => part.type === "text" && part.text.trimStart().startsWith(OPENCLAW_RUNTIME_CONTEXT));
130
+ }
131
+
118
132
  export function interactionPrefixContext(
119
133
  routerId: string,
120
134
  request: InteractionRequest,
@@ -13,6 +13,7 @@ import {
13
13
  codexConversationIdentity,
14
14
  codexRequestPurpose,
15
15
  codexRequestContext,
16
+ museCodeConversationIdentity,
16
17
  } from "../runtime/harness_identity/index.js";
17
18
 
18
19
  export async function handleResponses(
@@ -33,17 +34,16 @@ export async function handleResponses(
33
34
  ...(agentContext === undefined ? {} : { agentContext }),
34
35
  ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
35
36
  };
36
- const harnessConversationIdentity = codexConversationIdentity(
37
- request.headers,
38
- rawPayload,
39
- );
37
+ const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
40
38
  // Codex applies its global model_reasoning_effort setting to custom model
41
39
  // providers. That preference describes a direct model, not a heterogeneous
42
40
  // router, so let Dari select the reasoning effort for verified Codex turns.
43
41
  // Parsing above still validates malformed Responses reasoning controls.
44
- if (harnessConversationIdentity !== undefined && explicitModelId(payload.model) === null) {
42
+ if (codexIdentity !== undefined && explicitModelId(payload.model) === null) {
45
43
  payload.reasoning = undefined;
46
44
  }
45
+ const harnessConversationIdentity = codexIdentity
46
+ ?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
47
47
  return await handleRoutedCompletion({
48
48
  request,
49
49
  routerId,
@@ -19,6 +19,11 @@ export type HarnessConversationIdentity =
19
19
  kind: "openai_chat_session";
20
20
  value: string;
21
21
  qualifiers?: string[];
22
+ }
23
+ | {
24
+ kind: "muse_code_thread";
25
+ value: string;
26
+ qualifiers?: string[];
22
27
  };
23
28
 
24
29
  export type HarnessConversationScope = {
@@ -183,6 +183,16 @@ export function codexRequestContext(
183
183
  };
184
184
  }
185
185
 
186
+ // Muse Code sends its thread on every Responses request. The main agent's
187
+ // thread is its session, and each subagent has its own thread, so each keeps
188
+ // its own conversation and lease.
189
+ export function museCodeConversationIdentity(
190
+ headers: Headers,
191
+ ): HarnessConversationIdentity | undefined {
192
+ const threadId = boundedIdentity(headers.get("x-tbh-thread-id"), "x-tbh-thread-id");
193
+ return threadId === undefined ? undefined : { kind: "muse_code_thread", value: threadId };
194
+ }
195
+
186
196
  export function claudeCodeConversationIdentity(
187
197
  headers: Headers,
188
198
  metadataSessionId?: string,
@@ -11,6 +11,7 @@ export {
11
11
  codexConversationIdentity,
12
12
  codexRequestPurpose,
13
13
  codexRequestContext,
14
+ museCodeConversationIdentity,
14
15
  openCodeRequestContext,
15
16
  piRequestContext,
16
17
  } from "./extract.js";
@@ -65,7 +65,10 @@ const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*\/\S+$/;
65
65
  export async function loadGatewayConfig(path: string): Promise<GatewayConfig> {
66
66
  const file = Bun.file(path);
67
67
  if (!(await file.exists())) {
68
- throw new Error(`Gateway config not found at ${path}.`);
68
+ throw new Error(
69
+ `Gateway config not found at ${path}. Write one from dari-gateway.example.yaml, `
70
+ + "or set DARI_GATEWAY_CONFIG (in the container image, mount it at /config/dari-gateway.yaml).",
71
+ );
69
72
  }
70
73
  const text = await file.text();
71
74
  const raw: unknown = path.endsWith(".json") ? JSON.parse(text) : Bun.YAML.parse(text);
@@ -118,6 +118,10 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
118
118
  }
119
119
 
120
120
  if (import.meta.main) {
121
- const { server, close } = await startSelfHostedGateway();
122
- installShutdownHandlers(server, async () => close());
121
+ // Startup errors are configuration mistakes: print the message, not a stack.
122
+ const started = await startSelfHostedGateway().catch((error: unknown) => {
123
+ console.error(`dari-gateway: ${error instanceof Error ? error.message : String(error)}`);
124
+ process.exit(1);
125
+ });
126
+ installShutdownHandlers(started.server, async () => started.close());
123
127
  }