Skip to content

Commit 37f9d68

Browse files
feat(gateway): add embeddings proxy endpoint (#1068)
## Summary - Add new embeddings proxy API at `/api/gateway/embeddings` (and `/api/openrouter/embeddings`) that routes embedding requests to the correct upstream provider (OpenRouter, Mistral direct, OpenAI direct) with full BYOK, rate limiting, balance checking, and microdollar usage tracking. - Introduce `getEmbeddingProvider` for model-based provider routing: `mistralai/*` → Mistral API, `openai/*` → OpenAI API, all others → OpenRouter, with Vercel AI Gateway override when BYOK keys are available. - Add `buildUpstreamBody` to translate and strip provider-specific fields (e.g. Mistral's `output_dimension` vs OpenAI's `dimensions`), `parseEmbeddingUsageFromResponse` with a known-pricing table for direct-provider cost computation, and `extractEmbeddingPromptInfo` for audit logging. - KiloClaw: add `Dockerfile.local` and `push-dev.sh --local` flag for testing custom OpenClaw builds from local tarballs, install QMD globally, bump Node.js to 22.22.1. ## Verification - [X] Manual code review of all 3 commits - [X] Unit tests exist for `buildUpstreamBody`, `stripModelPrefix`, `extractEmbeddingPromptInfo`, `parseEmbeddingUsageFromResponse`, `getEmbeddingProvider`, and `checkOrganizationModelRestrictions` <img width="2411" height="1008" alt="image" src="https://github.com/user-attachments/assets/b59fae3b-9a57-4aa0-801a-08c7a8ef9515" /> ## Test Calls OpenRouter (default — e.g. Google embedding model): ```bash curl -X POST http://localhost:3000/api/gateway/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <YOUR_AUTH_TOKEN>" \ -d '{ "model": "google/text-embedding-004", "input": "The quick brown fox jumps over the lazy dog" }' ``` Mistral direct: ```bash curl -X POST http://localhost:3000/api/gateway/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <YOUR_AUTH_TOKEN>" \ -d '{ "model": "mistralai/mistral-embed", "input": ["Hello world", "Embedding test"] }' ``` OpenAI direct: ```bash curl -X POST http://localhost:3000/api/gateway/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <YOUR_AUTH_TOKEN>" \ -d '{ "model": "openai/text-embedding-3-small", "input": "Test embedding input" }' ``` ## Reviewer Notes - The embeddings route mirrors the chat-completions proxy pattern (auth → rate limit → BYOK → balance → provider routing → upstream fetch → usage tracking) but is non-streaming only. - Direct-provider cost computation uses a hardcoded pricing table in `EMBEDDING_PRICING`; for OpenRouter the `cost` field from the response is used instead. - `Dockerfile.local` is a near-copy of `Dockerfile` with the npm install swapped for a `COPY` + local tarball install — intended for dev/testing only.
2 parents c65c56b + 5c612bb commit 37f9d68

16 files changed

Lines changed: 994 additions & 18 deletions

File tree

kiloclaw/DEVELOPMENT_LOCAL.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,56 @@ request falls through to the proxy, which returns a bare `401 Unauthorized`
271271
instead of the expected `controller_route_unavailable` code. This surfaces as a
272272
`GatewayControllerError: Unauthorized` in the worker logs.
273273

274+
## Testing a Custom OpenClaw Build
275+
276+
To test a local OpenClaw fork (e.g., a feature branch with embeddings support),
277+
use `Dockerfile.local` which installs OpenClaw from a tarball in `openclaw-build/`
278+
instead of npm.
279+
280+
### 1. Build and pack your fork
281+
282+
```bash
283+
cd /path/to/openclaw
284+
pnpm build && npm pack
285+
```
286+
287+
This produces a file like `openclaw-2026.3.9.tgz` in the repo root.
288+
289+
### 2. Copy the tarball
290+
291+
```bash
292+
cp /path/to/openclaw/openclaw-*.tgz kiloclaw/openclaw-build/
293+
```
294+
295+
The `openclaw-build/` directory is git-ignored for `.tgz` files, so tarballs
296+
won't be committed.
297+
298+
### 3. Build and push with `--local`
299+
300+
```bash
301+
# From kiloclaw/
302+
./scripts/push-dev.sh --local
303+
```
304+
305+
This uses `Dockerfile.local` instead of the default `Dockerfile`. The script
306+
validates that a tarball exists in `openclaw-build/` before building. Everything
307+
else (tagging, pushing, `.dev.vars` updates) works the same as a normal push.
308+
309+
### 4. Deploy
310+
311+
1. Restart the KiloClaw worker: `pnpm run dev`
312+
2. From the dashboard (`localhost:3000`), destroy your existing instance
313+
(Settings tab → Destroy), then create/provision a new one.
314+
3. The new instance will run your custom OpenClaw build.
315+
316+
### Notes
317+
318+
- `OPENCLAW_VERSION` in `.dev.vars` is extracted from the main `Dockerfile`'s
319+
pinned npm version, so it won't reflect your fork's version. This is cosmetic.
320+
- Clean up old tarballs from `openclaw-build/` before copying a new one --
321+
the `COPY openclaw-build/openclaw-*.tgz` glob must match exactly one file.
322+
- Remember to `fly auth docker` before pushing (token expires after 5 minutes).
323+
274324
## Provisioning and Using an Instance
275325

276326
### From the dashboard (`localhost:3000`):

kiloclaw/Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FROM debian:bookworm-slim
22

33
# Install Node.js 22 (required by OpenClaw)
4-
ENV NODE_VERSION=22.13.1
4+
ENV NODE_VERSION=22.22.1
55
RUN apt-get update \
66
&& apt-get install -y --no-install-recommends \
77
ca-certificates curl gnupg git xz-utils unzip jq ripgrep rsync zstd \
@@ -48,6 +48,9 @@ RUN npm install -g openclaw@2026.3.8 \
4848
# Install ClawHub CLI
4949
RUN npm install -g clawhub
5050

51+
# Install QMD
52+
RUN npm install -g @tobilu/qmd
53+
5154
# Install mcporter (MCP server tooling)
5255
RUN npm install -g mcporter@0.7.3
5356

kiloclaw/Dockerfile.local

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
FROM debian:bookworm-slim
2+
3+
# Install Node.js 22 (required by OpenClaw)
4+
ENV NODE_VERSION=22.22.1
5+
RUN apt-get update \
6+
&& apt-get install -y --no-install-recommends \
7+
ca-certificates curl gnupg git xz-utils unzip jq ripgrep rsync zstd \
8+
build-essential python3 ffmpeg tmux chromium \
9+
&& ARCH="$(dpkg --print-architecture)" \
10+
&& case "${ARCH}" in \
11+
amd64) NODE_ARCH="x64" ;; \
12+
arm64) NODE_ARCH="arm64" ;; \
13+
*) echo "Unsupported architecture: ${ARCH}" >&2; exit 1 ;; \
14+
esac \
15+
&& curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz -o /tmp/node.tar.xz \
16+
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
17+
&& rm /tmp/node.tar.xz \
18+
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
19+
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
20+
&& echo "deb [arch=${ARCH} signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
21+
> /etc/apt/sources.list.d/github-cli.list \
22+
&& curl -fsSL https://downloads.1password.com/linux/keys/1password.asc \
23+
| gpg --dearmor --output /usr/share/keyrings/1password-archive-keyring.gpg \
24+
&& echo "deb [arch=${ARCH} signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/${ARCH} stable main" \
25+
> /etc/apt/sources.list.d/1password.list \
26+
&& mkdir -p /etc/debsig/policies/AC2D62742012EA22/ \
27+
&& curl -fsSL https://downloads.1password.com/linux/debian/debsig/1password.pol \
28+
| tee /etc/debsig/policies/AC2D62742012EA22/1password.pol > /dev/null \
29+
&& mkdir -p /usr/share/debsig/keyrings/AC2D62742012EA22 \
30+
&& curl -fsSL https://downloads.1password.com/linux/keys/1password.asc \
31+
| gpg --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg \
32+
&& apt-get update \
33+
&& apt-get install -y --no-install-recommends gh 1password-cli=2.32.1-1 \
34+
&& apt-get purge -y xz-utils \
35+
&& apt-get autoremove -y \
36+
&& rm -rf /var/lib/apt/lists/* \
37+
&& node --version \
38+
&& npm --version
39+
40+
# Install pnpm globally
41+
RUN npm install -g pnpm
42+
43+
# Install OpenClaw from local tarball (see openclaw-build/README)
44+
COPY openclaw-build/openclaw-*.tgz /tmp/openclaw.tgz
45+
RUN npm install -g /tmp/openclaw.tgz \
46+
&& rm /tmp/openclaw.tgz \
47+
&& openclaw --version
48+
49+
# Install ClawHub CLI
50+
RUN npm install -g clawhub
51+
52+
# Install QMD
53+
RUN npm install -g @tobilu/qmd
54+
55+
# Install mcporter (MCP server tooling)
56+
RUN npm install -g mcporter@0.7.3
57+
58+
# Install summarize (web page summarization CLI)
59+
RUN npm install -g @steipete/summarize@0.11.1
60+
61+
# Install Go (available at runtime for users to `go install` additional tools)
62+
ENV GO_VERSION=1.26.0
63+
RUN ARCH="$(dpkg --print-architecture)" \
64+
&& curl -fsSL https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz -o /tmp/go.tar.gz \
65+
&& EXPECTED_SHA256="$(curl -fsSL https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz.sha256)" \
66+
&& echo "${EXPECTED_SHA256} /tmp/go.tar.gz" | sha256sum -c - \
67+
&& tar -xzf /tmp/go.tar.gz -C /usr/local \
68+
&& rm /tmp/go.tar.gz
69+
# IMPORTANT: A Fly Volume is mounted at /root at runtime, which shadows
70+
# everything the image layer writes under /root.
71+
# - Pre-installed tools use GOBIN=/usr/local/bin so they survive the mount.
72+
# - At runtime, Go defaults to GOBIN=/root/go/bin (on the persistent volume),
73+
# so user-installed tools persist across restarts and are included in snapshots.
74+
# - npm/Node (installed to /usr/local) and apt packages (/usr/bin) are unaffected.
75+
ENV PATH="/usr/local/go/bin:/root/go/bin:$PATH"
76+
RUN GOBIN=/usr/local/bin go install github.com/steipete/gogcli/cmd/gog@v0.11.0 \
77+
&& GOBIN=/usr/local/bin go install github.com/steipete/goplaces/cmd/goplaces@v0.3.0 \
78+
&& GOBIN=/usr/local/bin go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@v0.0.2 \
79+
&& GOBIN=/usr/local/bin go install github.com/xdevplatform/xurl@v1.0.3 \
80+
&& GOBIN=/usr/local/bin go install github.com/steipete/gifgrep/cmd/gifgrep@v0.2.3 \
81+
&& go clean -cache -modcache
82+
83+
# Install uv (Python package manager, available at runtime).
84+
# Install to /usr/local/bin so the binary survives the Fly Volume mount at /root.
85+
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh \
86+
&& uv --version
87+
88+
# Install pinned Bun (build-time only) and compile the controller bundle.
89+
ARG BUN_VERSION=1.2.4
90+
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
91+
ENV PATH="/root/.bun/bin:$PATH"
92+
93+
ARG CONTROLLER_COMMIT=unknown
94+
# Changing this ARG value invalidates the COPY + RUN layers below it,
95+
# forcing a controller rebuild even when Docker thinks the source is unchanged.
96+
ARG CONTROLLER_CACHE_BUST=1
97+
COPY controller/ /tmp/controller-build/
98+
RUN cd /tmp/controller-build \
99+
&& bun install --frozen-lockfile \
100+
&& CONTROLLER_VERSION="$(date -u +'%Y.%-m.%-d')" \
101+
&& bun build src/index.ts --outfile=/usr/local/bin/kiloclaw-controller.js --target=node --minify \
102+
--define "KILOCLAW_CONTROLLER_VERSION=\"${CONTROLLER_VERSION}\"" \
103+
--define "KILOCLAW_CONTROLLER_COMMIT=\"${CONTROLLER_COMMIT}\"" \
104+
&& rm -rf /tmp/controller-build
105+
106+
# Create OpenClaw directories
107+
# When a Fly Volume is mounted at /root, these dirs persist across restarts.
108+
RUN mkdir -p /root/.openclaw \
109+
&& mkdir -p /root/clawd \
110+
&& mkdir -p /root/clawd/skills
111+
112+
# Copy startup script
113+
# Build cache bust: 2026-03-11-v60-tools-md
114+
RUN echo "9"
115+
COPY start-openclaw.sh /usr/local/bin/start-openclaw.sh
116+
COPY openclaw-pairing-list.js /usr/local/bin/openclaw-pairing-list.js
117+
COPY openclaw-device-pairing-list.js /usr/local/bin/openclaw-device-pairing-list.js
118+
RUN chmod +x /usr/local/bin/start-openclaw.sh
119+
RUN ls -l /usr/local/bin/start-openclaw.sh \
120+
&& head -n 1 /usr/local/bin/start-openclaw.sh | cat -v \
121+
&& od -An -t x1 -N 4 /usr/local/bin/start-openclaw.sh \
122+
&& ls -l /bin/bash
123+
124+
# Copy custom skills
125+
COPY skills/ /root/clawd/skills/
126+
127+
# Stage TOOLS.md outside /root so it survives the Fly Volume mount.
128+
# start-openclaw.sh copies it to /root/.openclaw/workspace/TOOLS.md on first boot.
129+
COPY container/TOOLS.md /usr/local/share/kiloclaw/TOOLS.md
130+
131+
# Expose the gateway port
132+
EXPOSE 18789
133+
134+
# Entrypoint: start the KiloClaw controller daemon
135+
CMD ["/usr/local/bin/start-openclaw.sh"]

kiloclaw/openclaw-build/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.tgz

kiloclaw/scripts/push-dev.sh

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
# timestamped tag, and update .dev.vars so the worker uses it on next
44
# machine create/restart.
55
#
6-
# Usage: ./scripts/push-dev.sh [app-name]
7-
# app-name defaults to FLY_APP_NAME from .dev.vars, or "kiloclaw-dev"
6+
# Usage: ./scripts/push-dev.sh [--local] [app-name]
7+
# --local Use Dockerfile.local to install OpenClaw from a local tarball
8+
# in openclaw-build/ instead of npm. Build your fork first:
9+
# cd /path/to/openclaw && pnpm build && npm pack
10+
# cp openclaw-*.tgz /path/to/kiloclaw/openclaw-build/
11+
# app-name defaults to FLY_APP_NAME from .dev.vars, or "kiloclaw-dev"
812
#
913
set -e
1014

@@ -15,6 +19,30 @@ fly auth docker
1519
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
1620
KILOCLAW_DIR="$(dirname "$SCRIPT_DIR")"
1721

22+
# Parse --local flag
23+
USE_LOCAL=false
24+
for arg in "$@"; do
25+
case "$arg" in
26+
--local) USE_LOCAL=true; shift ;;
27+
esac
28+
done
29+
30+
# Select Dockerfile
31+
if [ "$USE_LOCAL" = true ]; then
32+
DOCKERFILE="$KILOCLAW_DIR/Dockerfile.local"
33+
# Validate that a tarball exists in openclaw-build/
34+
if ! ls "$KILOCLAW_DIR"/openclaw-build/openclaw-*.tgz 1>/dev/null 2>&1; then
35+
echo "Error: No openclaw-*.tgz found in openclaw-build/." >&2
36+
echo "Build your fork first:" >&2
37+
echo " cd /path/to/openclaw && pnpm build && npm pack" >&2
38+
echo " cp openclaw-*.tgz $(cd "$KILOCLAW_DIR" && pwd)/openclaw-build/" >&2
39+
exit 1
40+
fi
41+
echo "Using Dockerfile.local (local OpenClaw tarball)"
42+
else
43+
DOCKERFILE="$KILOCLAW_DIR/Dockerfile"
44+
fi
45+
1846
# Read app name from argument, .dev.vars, or default
1947
APP_NAME="${1:-}"
2048
if [ -z "$APP_NAME" ] && [ -f "$KILOCLAW_DIR/.dev.vars" ]; then
@@ -26,7 +54,7 @@ TAG="dev-$(date +%s)"
2654
IMAGE="registry.fly.io/$APP_NAME:$TAG"
2755
GIT_SHA="$(git -C "$KILOCLAW_DIR" rev-parse HEAD 2>/dev/null || echo 'unknown')"
2856

29-
# Extract OpenClaw version from Dockerfile
57+
# Extract OpenClaw version from Dockerfile (only available when using npm install)
3058
OPENCLAW_VERSION=$(sed -n 's/.*openclaw@\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' "$KILOCLAW_DIR/Dockerfile" | head -1)
3159

3260
echo "Building + pushing $IMAGE (linux/amd64) ..."
@@ -38,7 +66,7 @@ trap 'rm -f "$METADATA_FILE"' EXIT
3866

3967
docker buildx build \
4068
--platform linux/amd64 \
41-
-f "$KILOCLAW_DIR/Dockerfile" \
69+
-f "$DOCKERFILE" \
4270
--build-arg "CONTROLLER_COMMIT=$GIT_SHA" \
4371
--build-arg "CONTROLLER_CACHE_BUST=$(date +%s)" \
4472
-t "$IMAGE" \
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { POST } from '@/app/api/openrouter/embeddings/route';

0 commit comments

Comments
 (0)