Thanks for considering contributing to devin-2api. This guide covers the architecture, the dev environment, and how to submit changes.
devin-2api is a protocol adapter: HTTP speaks OpenAI Responses / Chat Completions / Anthropic Messages, the upstream is Devin Connect, and a vendor-neutral model layer (internal/llm) isolates the two — so new upstreams can be added behind the same HTTP surface by implementing the adapter interface. Agent-loop semantics stay equivalent — not provider request-structure equivalent.
Every change must respect this boundary: an upstream must not bypass the llm intermediate layer — the HTTP protocol and any upstream protocol must never be mapped directly:
OpenAI / Anthropic HTTP ──► llm intermediate layer ──► Devin Connect RPC
(codec) (semantic model) (adapter)
- The HTTP codec only understands the OpenAI protocol, never Devin;
internal/llmis the single semantic model; each side only performs semantic conversion, never pass-through of structures;- Adapters only translate
internal/llm⇄ the upstream protocol and keep no HTTP-layer knowledge.
What makes a good upstream? Statelessness. The ideal upstream keeps no session state — every request is self-contained and carries the full conversation. Devin currently meets this: each GetChatMessage call carries the complete, replayable context, which keeps the gateway horizontally scalable and safe to replay.
┌────────────────────────────────────────────────────────┐
│ devin-2api │
HTTP client │ │ upstream
─────────────► │ /v1/responses /v1/chat/completions /v1/messages │ ┌──────────────────┐
OpenAI / │ (GET /v1/responses upgrades to WebSocket transport) │ │ Devin Connect │
Anthropic │ │ │ │ (server.codeium │
JSON / SSE │ ▼ │ │ .com) │
│ <surface>.DecodeRequest ──► llm.RequestMessages │ │ │
│ │ │ │ GetChatMessage │
│ ▼ │ │ (Connect, proto) │
│ adapter.Stream(ctx, RequestMessages) ───────────────►│ └──────────────────┘
│ │ │
│ ▼ │
│ llm.ResponseStream (event stream) │
│ │ │
│ ├─ streaming: AppendSSE / WriteSSEEvent ──► │
│ └─ non-stream: collectPumpedMessage ──► │
│ EncodeFinal ──► JSON │
└────────────────────────────────────────────────────────┘
Full request lifecycle:
- an API surface (
/v1/responses,/v1/chat/completions, or/v1/messages) receives the request JSON (body capped at 32 MiB);GET /v1/responsesupgrades to the OpenAI Responses WebSocket transport instead; - that surface's
DecodeRequestconverts the request intollm.RequestMessages(system prompt, message history, tool definitions) plus generation options; adapter.Streamhands the vendor-neutral context to the configured adapter and returns anllm.ResponseStream;- the Devin adapter translates the intermediate model into a
GetChatMessageRequest(protobuf), reads upstream frames over a Connect stream, and aresponseDecoderinterprets each frame into zero or morellm.ResponseEvents; - output is split by the request's
streamoption:- streaming: the surface's
StreamEncoderexpands events into its own typed SSE (response.output_text.delta/chat.completion.chunk/content_block_delta, …); - non-streaming:
done/errorevents are aggregated into a finalAssistantMessageencoded as the surface's JSON shape.
- streaming: the surface's
The semantic model shared by all adapters, defined in internal/llm:
Message history is a complete, replayable conversation across providers: thinking signatures, tool-call IDs, and usage fields are designed to be passed verbatim into the next request round (see the comments on TextSignature, ThinkingSignature, etc. in request.go).
- Go 1.27.1 (see
go.mod) - Task: the proto → Go binding generation entrypoint (
Taskfile.yml) - Generating the bindings requires
protoc+protoc-gen-go+protoc-gen-connect-go(versions pinned inTaskfile.yml):
brew install protobuf
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.20.0- Formatting toolchain:
npm installpins prettier/markdownlint-cli2/git-format-staged frompackage.json,brew install autocorrect, thenpre-commit install— commits run markdownlint --fix thenautocorrect | prettieron*.md, and gofmt on*.go; the latter two rewrite only staged content in the index via git-format-staged, never the working tree. Manual checks:npm run format:check/npm run lint:md - Run once so
git blameskips the mass-format commit:git config blame.ignoreRevsFile .git-blame-ignore-revs
# Regenerate the proto Go bindings (committed to git — only needed after outputs/devin-proto changes)
task generate
# Run the full test suite
go test ./...
# Run locally (needs config.yaml first, see README → Quick start)
go run ./cmd/devin-2api -config config.yamlAll three surfaces decode into the same llm.RequestMessages and re-encode the same llm.ResponseEvent stream — so upstream quirks (tool-call pairing, fingerprint sanitizing, thinking signatures) are handled once, centrally.
POST /v1/responses— subset of the OpenAI Responses API:input(string or items:message,function_call,function_call_output,custom_tool_call,custom_tool_call_output,reasoning),instructions,tools,stream,max_output_tokens,temperature,top_p,tool_choice,parallel_tool_calls,previous_response_id,prompt_cache_key,user.contentparts:input_text,output_text,text,input_image(base64 data URL),input_file/file(base64file_dataorfile_url;file_idrejected),video/video_url/input_video.toolsentries may also becustom(freeform),namespace(flattened sub-tools), orweb_search(server-managed — executed via the upstream search RPC).GET /v1/responsesupgrades to the OpenAI Responses WebSocket transport (responses_websockets=2026-02-06), mapping each SSE event to one text frame.POST /v1/chat/completions— OpenAI Chat:messages,tools,tool_choice,stream/stream_options.include_usage,max_tokens/max_completion_tokens,temperature,top_p,top_k,seed,stop,parallel_tool_calls,prompt_cache_key,user,n, plus legacyfunctions/function_call. Content parts take the same image/document/video forms as Responses. Unconsumed top-level fields (response_format,reasoning_effort,store, …) are recorded as dropped, not silently ignored.POST /v1/messages— Anthropic Messages:system,messages(text /image/document/file/video/video_url/tool_use/tool_result/server_tool_use/server_tool_result/thinking+signature/redacted_thinkingblocks),tools,tool_choice,max_tokens,stream,temperature,top_p,top_k,stop_sequences,metadata.user_id(used as the session key for cache affinity). Server-tool declarations (web_search_*,web_fetch_*,code_execution_*, …) have no upstream counterpart and are recorded as dropped — except a request whose tools are allweb_search_*, which short-circuits into a single managed search beforeGetChatMessage. The top-levelthinkingparam has no upstream counterpart and is also recorded as dropped.GET /v1/models+GET /v1/models/{model}— upstream model list with capability flags (context_tokens,max_output_tokens,supports_tool_calls,supports_parallel_tool_calls,supports_thinking,preserve_thinking,supports_images,supports_documents,supports_video).
Intermediate tools are converted into Devin native function tools (name and JSON Schema constraints preserved, while natural-language annotations such as description/title — which upstream may misinterpret as classification hints — are stripped); non-empty tool descriptions are additionally injected into the system prompt as <tool name="..."> blocks so the model understands their purpose. See internal/adapter/devin/tool_definition.go.
When debug.enabled: true, each request gets a staged debug record keyed by a dir name (<request-time>) inside devin-2api.db under the state dir — one-shot payloads (meta.json, 01–03, error.json, attachments/*) go to the debug_files table, streamed JSONL (04–06) goes to debug_chunks — useful for pinpointing failures at any hop of "HTTP ⇄ intermediate ⇄ upstream":
meta.json # request outcome summary (status, model, duration)
01-http-request.json # raw HTTP request (redacted)
02-request-messages.json # converted intermediate request context
03-devin-request.json # proto request sent upstream (as JSON); retries add
# .attemptN.json shards, managed search calls use the
# .searchN stem under the same prefix
04-devin-response.jsonl # raw upstream response frames
05-response-events.jsonl # intermediate response events
06-http-response.jsonl # final response/SSE events written to the client
error.json # first failing stage and error
attachments/ # externalized image attachments (deduped by SHA-256)
Concurrent requests in the same second are distinguished by an incrementing suffix in the dir name.
The logs table holds one summary row per completed request (result, model, error_stage, token classes, key hash) — it outlives payload retention and backs the panel's usage aggregation. quota_samples holds quota snapshots sampled every debug.quota_interval_minutes. Retention is tiered: debug.retention_days deletes a dir's rows by age, debug.max_total_mb evicts oldest dir groups first, debug.payload_hours strips the large stage payloads (03/04/06/attachments) while keeping meta/error/01/02/05 evidence, and debug.keep_error_dirs protects the newest N failed dirs during size eviction.
The admin panel at /web (login: dashboard.password) renders these logs as a request browser and exposes /admin/* for programmatic access — /admin/api returns the endpoint catalog; PUT /admin/settings/debug_log_enabled hot-switches request logging without a restart.
- Tests pass:
go test ./... - Linted:
golangci-lint runis clean (.golangci.yml: default:none + explicit bodyclose/errcheck/gosec/govet/revive/staticcheck/unused) — CI runs the same job - Formatted:
gofmt -l .produces no output (orgolangci-lint fmtfor gofmt+goimports) - Comment conventions: follow the repo's Go comment conventions (
.agents/skills/go-comment-conventions) — exported symbols get doc comments, field comments explain "why", not restate the code - Docs linted: commits touching
*.mdrun the pre-commit pipeline; if a hook rewrites a file, re-stage it and commit again - No real tokens:
config.yamlis gitignored; keep it that way and make sure no realdevin.accounts[].tokenends up in any committed file (pre-commit runs gitleaks to catch committed secrets)
- Fork the repo and create a feature branch off
main(e.g.fix/sse-close,feat/stream-options); - One logical change per commit; write commit messages in the imperative, saying what and why;
- If the change alters protocol semantics or adapter behavior, update the README and tests accordingly;
- Open a Pull Request and describe:
- the purpose and how you verified it;
- which layer it touches ("HTTP ⇄ intermediate ⇄ upstream");
- whether an upstream protocol upgrade is involved (descriptor changes, see below).
Releases follow SemVer. While the project is in the 0.x phase, breaking changes bump the minor version (v0.1.0 → v0.2.0), not the major one.
A release is a v-prefixed tag published via scripts/release/release.sh. The script derives the next version from Conventional Commits since the last tag, then gates on HEAD == origin/main and a green CI workflow run for that commit — tags are only ever placed on pushed, tested commits:
scripts/release/release.sh # dry-run: next version, changelog, gate status
scripts/release/release.sh --publish # create + push the annotated tagPushing the tag triggers the release.yml workflow (full test suite, per-platform binaries built with -X main.version=<tag>, packaged into a multi-arch image pushed to GHCR ghcr.io/wncfht/devin2api, plus a GitHub Release whose notes come from the tag annotation).
Notes:
- a stable tag
v0.1.0publishes image tags0.1.0,0.1,0, andlatest; pre-releases likev0.2.0-rc.1only get the exact0.2.0-rc.1tag (no floating aliases); - GHCR is always published via
GITHUB_TOKEN— no secrets to configure. SettingDOCKERHUB_USERNAME/DOCKERHUB_TOKENadditionally mirrors the image to Docker Hub; - tags are immutable once pushed (enforced by a repo ruleset); fix a bad release by releasing a new version, never by rewriting the tag.
The full pipeline is fixed in Taskfile.yml as two steps:
task extract BINARY=<upstream-binary> ① binary → outputs/devin-proto/ (not reproducible, depends on packet capture)
task generate ② proto → outputs/devin-proto-go/ (reproducible, standard toolchain)
Scans compiled binaries for embedded FileDescriptorProtos and reconstructs .proto sources — used to recover the upstream protocol when no original .proto files exist (this project used it to extract Devin's 63 descriptors, see outputs/devin-proto/).
Two ways to run it:
# Option 1 (recommended): Taskfile wrapper, output fixed to outputs/devin-proto/
task extract BINARY=/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_arm
# Option 2: call the underlying tool directly with any output directory
# (useful for extracting to a temp dir and comparing first)
go run ./cmd/protoextract <source-binary> <output-directory>Arguments:
<source-binary>— the compiled artifact to analyze (a regular file, e.g. the Devin/Windsurf language server binary);<output-directory>— the output directory, emptied and rebuilt; fixed tooutputs/devin-proto/via Option 1, or a temp dir (e.g./tmp/extract-test) via Option 2 to compare before overwriting.
Outputs:
descriptors.pb— the complete descriptor set preserving original package names, syntax, options, and file boundaries;all-protos.proto— a flattened single-file bundle that compiles as-is;manifest.json— per-descriptor metadata and symbol mappings.
Standard procedure after an upstream upgrade (the new binary may embed new descriptors):
-
Extract to a temp dir and diff against the committed version:
go run ./cmd/protoextract <new-binary> /tmp/extract-test diff <(grep '"name"' outputs/devin-proto/manifest.json | sort) \ <(grep '"name"' /tmp/extract-test/manifest.json | sort)
-
Confirm the added/changed descriptors are expected, then overwrite
outputs/devin-proto/(Option 1) or copy the temp outputs; -
Run
task generateto regenerate the Go bindings; -
Verify extraction quality: check
descriptor_countandmissing_dependenciesinmanifest.json, and compile-checkall-protos.protowithprotoc --descriptor_set_out=/dev/null all-protos.proto.
The command empties the output directory and guards against destructive paths (filesystem root, home directory, the source binary, etc.). Step ① depends on the upstream binary and packet capture, so it is not reproducible and its outputs must be committed to git.
The Connect client bindings (outputs/devin-proto-go/, referenced via replace local/devinproto => ./outputs/devin-proto-go in go.mod) are generated from outputs/devin-proto/all-protos.proto:
task generateThe generated code is committed to git (it is the replace target in go.mod), so a fresh clone builds without running this — re-run it only when the upstream descriptors change. The generation parameters are fixed in Taskfile.yml (protoc with the Mall-protos.proto=local/devinproto mapping); Docker builds copy the committed bindings instead of regenerating (golang:1.27.1-alpine → alpine:3.22, binary at /app/devin-2api).
cmd/
devin-2api/ # entrypoint: load config, assemble deps, serve HTTP
protoextract/ # tool: extract embedded protobuf descriptors from binaries
protocensus/ # tool: census/diff descriptor sets across debug log dirs (task census)
probe/ # tool: direct Connect-RPC live probes against the upstream
upstreamstub/ # tool: local upstream stub replaying transport failure scenarios
loadtest/ # tool: load generator for the HTTP surfaces
internal/
accounts/ # upstream account pool domain: account CRUD + credential resolution (config ∪ DB overlay)
adapter/ # adapter boundary (interface Adapter + request-class plumbing)
devin/ # Devin Connect adapter: request/response conversion + tool-definition sanitizing
api/
anthropic/
messages/ # Anthropic Messages HTTP codec
openai/
chat/ # OpenAI Chat Completions HTTP codec
responses/ # OpenAI Responses HTTP codec (JSON request, JSON/SSE response)
common/ # shared surface plumbing: error normalization, tool-choice parsing
app/ # chi routing, request lifecycle, error handling
authtoken/ # downstream API token store (auth_tokens table): /v1 admission concurrency/cost/model limits
ccpanel/ # admin panel (ccLoad contract): /web, /public, /dashboard/*, /admin/*
config/ # YAML config loading and validation
debuglog/ # per-request staged debug logs (redaction + externalized images)
httpproxy/ # upstream HTTP client construction (proxy, force_http1)
llm/ # vendor-neutral intermediate model (request, response, event stream)
modelreg/ # global model registry (model_registry table): disable/redirect overlays before alias resolution
obs/ # process/HTTP metrics behind /admin/runtime-metrics
randid/ # random ID generation (X-Request-Id / debug dir names)
store/ # SQLite persistence for all runtime state (devin-2api.db tables)
upstream/ # shared upstream wire helpers (request metadata, auth transport)
outputs/
devin-proto/ # raw descriptors extracted from the Devin binary, committed
devin-proto-go/ # generated Go bindings, committed; regenerated by task generate
e2e/
devin-client/ # end-to-end Devin connect client call example (hand-written)
New adapters (for other upstreams) should implement the Adapter interface in internal/adapter, built entirely on the internal/llm semantic model, without introducing HTTP-layer knowledge.
