OpenAI-compatible HTTP API for duck.ai (the free DuckDuckGo AI chat), written in Go. Built-in tool calling is supported out of the box (the bridge maps OpenAI tools[] onto duck.ai's WebSearch / GenerateImage / NewsSearch / etc. built-ins).
✅ Working — end-to-end verified against https://duck.ai/ with model gpt-5.6-luna, including:
- ✅ OpenAI-shaped
POST /v1/chat/completions(streaming + non-streaming) - ✅ Tool calling:
tools[]→ duck.ai built-inWebSearch/RelatedSearchTerms(real web results, citations,tool_callsdelta streaming) - ✅
GET /v1/models(gpt-5.6-luna, gpt-5.4-mini, claude-haiku-4-5, mistral-small-4, gpt-oss-120B, gemma-4-31B) - ✅ Non-tool chats (PONG-style smoke test passes)
- Browser capture (one-time per session): duck.ai's PoW is a JavaScript challenge that requires a real DOM (
window.parent,document.querySelector,iframe.contentWindow.get, etc.) andcrypto.subtle.digest('SHA-256', ...). No pure-Go library can run it. So the bridge does not solve the PoW itself; instead you paste in a pre-solved value captured from a real browser. The hash stays valid for several hours. - Go client (
internal/duckai): a thin*http.Clientthat sendsPOST /duckchat/v1/chatwith the capturedX-Vqd-Hash-1and an RSA-OAEP-256durableStreamenvelope, then parses the SSE stream. - Go server (
internal/server): OpenAI-compatiblePOST /v1/chat/completions. Translates OpenAI messages/tools/stream into duck.ai's request shape and translates the duck.ai SSE stream into OpenAI SSE chunks.tools[]names are matched (web_search,image,news,weather, etc.) and forwarded to duck.ai'smetadata.toolChoiceflags.
# 1. Install playwright
pip install playwright
playwright install chromium
# 2. Open https://duck.ai in Chromium and watch the network panel.
# Fire any chat message. The /duckchat/v1/chat request will
# carry an X-Vqd-Hash-1 header. Copy its value (it's a long
# base64 string starting with "eyJ").
# 3. Save it:
echo "eyJ...==..." > /tmp/duckai_vqd.txt- Open
https://duck.ai/, accept the page, type any message, hit send. - Open DevTools → Network → find the
chatrequest → right-click → Copy → Copy request headers. - Extract the value of
x-vqd-hash-1:. Save to a file.
# This requires playwright + an open chromium with --remote-debugging-port=9222
# See /tmp/duckai_capture/ in the development session for the
# worked example. The bridge does NOT bundle a browser to keep
# the binary small.go build -o duckai-bridge ./cmd/duckai-bridge
./duckai-bridge -listen :8788 -vqd-file /tmp/duckai_vqd.txt
# Or inline:
./duckai-bridge -listen :8788 -vqd "eyJ..."
# Or via env:
DUCKAIVQD="eyJ..." ./duckai-bridge -listen :8788| Method | Path | Notes |
|---|---|---|
| GET | /healthz |
liveness |
| GET | /v1/models |
OpenAI model list |
| POST | /v1/chat/completions |
OpenAI chat (stream + non-stream + tool calls) |
# Health
curl -s http://127.0.0.1:8788/healthz
# Models
curl -s http://127.0.0.1:8788/v1/models
# Non-streaming chat
curl -s -X POST http://127.0.0.1:8788/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"Reply PONG only."}]}'
# Tool calling (the main feature)
curl -s -X POST http://127.0.0.1:8788/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-luna",
"messages": [{"role": "user", "content": "Latest Python version?"}],
"tools": [{"type": "function", "function": {"name": "web_search"}}]
}'OpenAI's tools[] carries user-defined function definitions. duck.ai only exposes a fixed set of BUILT-IN tools (it cannot run user-defined functions). The bridge translates by matching the function name (case-insensitive substring):
OpenAI function.name matches |
duck.ai metadata.toolChoice |
|---|---|
web_search, websearch, google, anything ending in _search |
WebSearch: true |
news, news_search |
NewsSearch: true |
video, videos, video_search |
VideosSearch: true |
local, local_search, place, maps |
LocalSearch: true |
weather, forecast, get_weather |
WeatherForecast: true |
image, generate_image, dalle, dall-e, create_image |
GenerateImage: true |
Unknown names are silently dropped. If tool_choice names a specific tool the bridge enables only that one; if it can't be mapped, it falls back to WebSearch (the most common "I want the model to call something" intent).
~/apps/duckai-bridge/
├── go.mod
├── cmd/duckai-bridge/
│ └── main.go — flag/env parsing, signal handling
├── internal/
│ ├── vqd/ — (legacy) JS PoW solver via goja; not used in
│ │ │ the default path, kept for future use
│ │ ├── polyfills.go
│ │ ├── pow.go
│ │ └── pow_test.go
│ ├── duckai/
│ │ ├── client.go — duck.ai HTTP client + SSE parser
│ │ └── jwk.go — RSA priv-key reconstruction helper
│ └── server/
│ └── server.go — OpenAI ↔ duck.ai translation, tool mapping
└── README.md
- No automatic VQD refresh. The X-Vqd-Hash-1 hash expires (typically after several hours of heavy use or a server-side rate limit). When the upstream starts returning 418 ERR_CHALLENGE you must capture a new hash. For a long-running deployment, wrap the bridge in a supervisor that re-captures the hash on 418 errors.
- No custom user-defined tools. duck.ai only supports its built-in tool set; user-defined OpenAI
tools[]are mapped to whichever built-in matches the function name. - No login / no auth. This is the public, unauthenticated duck.ai endpoint.
MIT.