When ccproxy transforms LLM API traffic — rerouting an OpenAI-format request to Anthropic, or channeling a Gemini SDK call through a different endpoint — the resulting outbound request is structurally correct but potentially incomplete. The lightllm transform produces valid API payloads, but the non-obvious compliance metadata that makes a request indistinguishable from a native SDK call can be lost: beta headers, user-agent patterns, system prompt preambles, client identity markers, and session metadata.
ccproxy solves this through request shaping: it ships sanitized, known-good request templates for built-in providers, then injects the incoming request's content into the template's compliance envelope at runtime.
When ccproxy's lightllm transform converts a request, the outbound payload is API-correct but may lack the compliance metadata a native SDK request carries:
- Beta headers:
anthropic-beta: prompt-caching-2024-07-31,... - Client identity:
x-stainless-arch,x-stainless-os,x-stainless-runtime - User-agent: The exact UA string the target SDK sends
- System prompt structure: Claude Code's compliance preamble as the first system block
- Metadata identity: Nested JSON in
metadata.user_idwithdevice_id,account_uuid,session_id
A shape is a known-good request carrying this complete compliance envelope. Packaged defaults and explicit full overrides are stored as response-free .mflow files: request state plus preserved flow metadata. Advanced local customization is stored as a quilt-style patch queue against a deterministic shape.json projection of that request.
ccproxy ships sanitized default shapes for built-in shaping providers. These bundled shapes are read-only package assets and are used automatically; normal users do not need to capture their own shapes. They are prepared for public distribution as request-only .mflow files: no response body, no auth/cookie headers, and no ccproxy flow-record metadata. Advanced local overrides live as small .patch files under $CCPROXY_CONFIG_DIR/shapes/{provider}/.
Base resolution order is:
- User full override:
{shapes_dir}/{provider}.mflow - Bundled default:
ccproxy/templates/shapes/{provider}.mflow - No shape: the shape hook no-ops and logs the missing provider shape
After the base is loaded, ccproxy applies the user patch queue from {shapes_dir}/{provider}/series if present.
Most users should never need this section. Use the packaged shapes first. If a request used to work and now fails after the upstream CLI or SDK changed, first upgrade ccproxy and try again. A newer ccproxy release may already ship a refreshed packaged shape.
Use this manual guide when all of these are true:
- You are using a built-in shaped provider such as
anthropicorgemini. - The packaged shape fails, usually with a provider-side 400, 401, or 403.
- There is not yet a ccproxy release with an updated packaged shape.
- The provider's official CLI still works on your machine when run normally.
In plain language: you will run the provider's real CLI once through ccproxy's
inspector, let ccproxy record the working request shape, and then ask ccproxy
to save only the useful request envelope. Your prompts and credentials are not
put into the packaged defaults; this creates a local override in your own
$CCPROXY_CONFIG_DIR/shapes/ directory.
Use a boring test prompt, not private work. The local patch or .mflow can
include pieces of the captured request, and it is meant to stay on your machine.
Make sure the real provider CLI is installed and logged in:
# Anthropic / Claude Code
claude -p "reply with ok"
# Gemini CLI
gemini -p "reply with ok"Make sure ccproxy is running in another terminal:
ccproxy startFor this repository's dev shell, use the supervised dev instance instead:
just upCheck that ccproxy is reachable:
ccproxy status --proxy --inspectThe manual capture command uses ccproxy run --inspect. That mode requires the
WireGuard namespace prerequisites listed in the README. If ccproxy run --inspect reports missing system tools or namespace permissions, fix those
first; ccproxy shapes save cannot create a shape until ccproxy has inspected
one real CLI request.
This makes the next steps less confusing. It does not delete your saved shapes;
it only clears the temporary inspection history shown by ccproxy flows.
ccproxy flows clear --allChoose the provider you are fixing.
For Anthropic / Claude Code:
ccproxy run --inspect -- claude --model haiku -p "Reply with exactly: manual shape ok"For Gemini:
ccproxy run --inspect -- gemini -m gemini-3.1-pro-preview -p "Reply with exactly: manual shape ok"The important part is that the command succeeds. The exact wording of the prompt is not special; it is just short and easy to recognize in the flow list.
List the captured flows:
ccproxy flows listFor Anthropic, look for a successful request to api.anthropic.com whose path
starts with /v1/messages.
For Gemini, look for a successful request to cloudcode-pa.googleapis.com
whose path starts with /v1internal:.
If you do not see a matching 2xx flow, stop here. The shape would be based on a
failed or unrelated request. Check ccproxy logs -f, then run the CLI request
again.
Use the provider-specific command below. The --jq filter picks the newest
matching provider request from the flow list, so you do not need to copy a flow
ID by hand.
For Anthropic:
ccproxy shapes save anthropic \
--jq 'map(select(.request.pretty_host == "api.anthropic.com" and (.request.path | startswith("/v1/messages")))) | .[-1:]'For Gemini:
ccproxy shapes save gemini \
--jq 'map(select(.request.pretty_host == "cloudcode-pa.googleapis.com" and (.request.path | startswith("/v1internal:")))) | .[-1:]'Expected output looks like this:
Saved shape patch for anthropic: /home/you/.config/ccproxy/shapes/anthropic/0001-local-shape.patch
or:
Shape patch for anthropic is unchanged.
Both are acceptable. unchanged means your local capture already matches the
current base shape.
Run the SDK, app, or harness that was failing. You do not need to restart ccproxy; shape patches are read from disk when the shape hook picks the shape.
If you want a small direct check, use the same style as the packaged-shape E2E tests: make one SDK request through ccproxy with the sentinel key and ask for a short exact phrase.
For Anthropic SDK clients, the important settings are:
api_key = "sk-ant-oat-ccproxy-anthropic"
base_url = "http://127.0.0.1:4000"For Gemini SDK clients, the important settings are:
api_key = "sk-ant-oat-ccproxy-gemini"
base_url = "http://127.0.0.1:4000/gemini"Then compare the client request with the final request ccproxy forwarded:
ccproxy flows compareYou should see your actual prompt content plus the provider's native headers and request structure in the forwarded request.
Patch mode is preferred because it keeps your local change small and layered on
top of the packaged default. If ccproxy shapes save PROVIDER says there is no
base shape, or if the upstream request changed so much that a patch is not
useful, save a full request-only local override instead:
ccproxy shapes save anthropic --mflow \
--jq 'map(select(.request.pretty_host == "api.anthropic.com" and (.request.path | startswith("/v1/messages")))) | .[-1:]'ccproxy shapes save gemini --mflow \
--jq 'map(select(.request.pretty_host == "cloudcode-pa.googleapis.com" and (.request.path | startswith("/v1internal:")))) | .[-1:]'--mflow writes a request-only local override such as
~/.config/ccproxy/shapes/anthropic.mflow. It is still local to your machine.
If the local shape makes things worse, delete it and ccproxy will fall back to the packaged default on the next request:
rm -rf ~/.config/ccproxy/shapes/anthropic ~/.config/ccproxy/shapes/anthropic.mflow
rm -rf ~/.config/ccproxy/shapes/gemini ~/.config/ccproxy/shapes/gemini.mflowUse only the provider line you actually changed.
If you open an issue or ask for help, include:
- The provider you refreshed:
anthropicorgemini. - The CLI version:
claude --versionorgemini --version. - The ccproxy version.
- The upstream status code from the failing request.
- Whether
ccproxy shapes save PROVIDERwrote a patch or required--mflow.
Do not paste auth tokens, cookies, full request bodies, or .mflow files into a
public issue.
This reference explains what the commands in the manual guide write to disk and how ccproxy uses those files at runtime.
ccproxy shapes save resolves the current flow set with the same --jq
filtering used by ccproxy flows, then invokes MitmwebClient.save_shape() →
POST /commands/ccproxy.shape → ShapeCaptureAddon.save_shape_artifact()
(inspector/shape_capturer.py). The addon validates the flow (POST method,
JSON content-type, capture.path_pattern regex), prepares a local shape by
removing response-side state and auth/transport/internal request headers,
preserves serializable flow metadata for local overrides, embeds any captured
replay fingerprint under ccproxy.fingerprint.profile, and then:
- Default mode: canonicalizes the selected request and provider base into
shape.json, writes a standard unified diff as{shapes_dir}/{provider}/0001-local-shape.patch, and lists it in{shapes_dir}/{provider}/series. --mflowmode: writes a response-free{shapes_dir}/{provider}.mflowoverride viaFlowWriter.
ShapeStore (shaping/store.py) maintains one shape root containing optional full overrides and patch queues:
~/.config/ccproxy/shapes/
├── anthropic.mflow
├── anthropic/
│ ├── series
│ └── 0001-local-shape.patch
├── gemini/
│ ├── series
│ └── 0001-local-shape.patch
└── ...
<package>/ccproxy/templates/shapes/
├── anthropic.mflow
├── gemini.mflow
└── ...
- Append-only: Each
add()appends; previous shapes are preserved - User overrides win:
pick()returns the latest user shape first, then the bundled default - Patch queues apply last:
{provider}/seriespatches apply to either the user override or bundled default - Native format: Inspectable via
mitmweb --rfile - Thread-safe: All operations under a threading lock
- Clear means revert: Clearing a user shape deletes the override and patch queue; the bundled default remains available
shaping:
enabled: true
shapes_dir: ~/.config/ccproxy/shapesThe series file is a quilt-style ordered patch manifest:
# applied top to bottom
0001-local-shape.patch
0002-another-change.patch -p1
Each patch is a standard unified diff against virtual shape.json. Git-style paths (a/shape.json, b/shape.json) use the default -p1 strip level.
The shape is the proven request envelope — a packaged or local flow carrying the full compliance metadata. At runtime, ccproxy creates a working copy, strips configured headers, injects the incoming request's content into declared fields, runs shape hooks (inner DAG) for dynamic operations, and stamps the result onto the outbound flow.
The identity/content boundary is declared per-provider in YAML config. content_fields lists the body keys that come from the incoming request. Everything NOT listed persists from the shape — compliance headers, beta flags, system prompt preamble, metadata skeleton, client identity markers. This inversion means the system doesn't need to enumerate what the envelope contains; it declares what it intends to inject.
Shape (packaged/local flow)
│
▼
Deep copy shape.request → working Shape
│
▼
┌──────────────────────────┐
│ STRIP phase │ Strip headers (auth, transport)
│ │ per profile.strip_headers
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ INJECT phase │ Two-pass strip & fill of
│ │ profile.content_fields using
│ │ profile.merge_strategies
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ SHAPE HOOKS phase │ Run profile.shape_hooks via
│ │ inner DAG (e.g., UUID re-roll)
└──────────┬───────────────┘
│
▼
shape_ctx.commit() Flush body mutations to working.content
│
▼
apply_shape(working, ctx, Stamp shape headers + query params + body
profile.preserve_headers) onto outbound flow, preserving auth + host
│
▼
Outbound flow carries shape's
compliance envelope with the
incoming request's content
The shape hook (hooks/shape.py) runs last in the outbound pipeline. Its guard condition (shape_guard) ensures it only fires when:
- The flow entered via reverse proxy OR has the
ccproxy.auth_injectedflag - AND the
FlowRecordhas a completedTransformMeta
WireGuard passthrough flows (already authentic) and flows without a transform are not shaped.
When it fires:
- Gets the provider from
record.transform.provider_type - Looks up
ProviderShapingConfigfromconfig.shaping.providers[provider] store.pick(provider)— fetches the most recent user shape, falling back to the bundled default, then applies the provider patch queuehttp.Request.from_state(captured.request.get_state())— deep-copies as a workingShapestrip_headers(shape_ctx, profile.strip_headers)— removes configured headers_inject_content(shape_ctx, incoming_ctx, profile)— content injection per merge strategy- Runs shape hooks from
profile.shape_hooksvia innerHookDAG shape_ctx.commit()— flushes body mutations to working request bytesapply_shape(working, ctx, profile.preserve_headers)— stamps onto the outbound flow
_inject_content(shape_ctx, incoming_ctx, profile) operates in two passes:
Pass 1 — Strip: For each key in content_fields, snapshot the shape's value (needed for non-replace strategies), then remove the key from the shape body. After this pass, the shape contains only envelope fields.
Pass 2 — Fill: For each key in content_fields, inject from the incoming request per the field's merge strategy:
| Strategy | Behavior | Use case |
|---|---|---|
replace (default) |
Incoming value replaces shape value. If incoming doesn't have the field, it stays absent. | model, messages, tools, stream, max_tokens |
prepend_shape |
Shape's original value prepended before incoming: [*shape, *incoming]. Strings auto-wrapped to [{type: text, text: ...}]. Append :N to keep only the first N shape elements (e.g. prepend_shape:2). |
system (shape preamble + incoming prompt) |
append_shape |
Incoming first, shape appended: [*incoming, *shape]. Same string normalization. Append :N to keep only the first N shape elements. |
Alternative system ordering |
drop |
Field removed entirely (already stripped in pass 1). | Suppress a field |
Null values from either side are coerced to empty lists for safe spreading.
Shape hooks handle operations that can't be expressed as field injection — things that require cross-field logic, ID generation, or structural body mutations. They are standard @hook(reads=..., writes=...) decorated functions, DAG-ordered by their declarations and executed via HookDAG against the shape context. All raw body access uses glom (glom(), assign(), delete() from the glom package) — the standard primitive for ctx._body mutations across the entire hook system. The reads/writes declarations use glom dot-paths (e.g. "metadata.user_id", "system.*.cache_control") which the DAG resolves to root fields for dependency ordering.
Each hook has signature (ctx: Context, params: dict) -> Context where ctx is the shape context. The incoming pipeline context is available via params["incoming_ctx"].
Shape hooks can be either bare module paths (all @hook-decorated functions in the module are loaded) or {hook, params} dicts for parameterized hooks with a model= Pydantic schema:
shape_hooks:
# Bare module path — loads all @hook functions from the module
- ccproxy.shaping.regenerate
# Parameterized hook — dict with hook path and params
- hook: ccproxy.shaping.caching.strip
params:
paths: ["system.*.cache_control"]| Hook | Module | Purpose |
|---|---|---|
regenerate_user_prompt_id |
ccproxy.shaping.regenerate |
Re-rolls user_prompt_id via glom()/assign(). reads/writes=["user_prompt_id"]. |
regenerate_session_id |
ccproxy.shaping.regenerate |
Parses nested JSON in metadata.user_id via glom(), re-rolls session_id into a fresh UUID4. reads/writes=["metadata.user_id"]. |
strip |
ccproxy.shaping.caching.strip |
Deletes values at glom dot-paths via delete(). Parameterized via StripParams(paths: list[str]). reads/writes=["system.*.cache_control", "tools.*.cache_control", "messages.*.content.*.cache_control"]. |
insert |
ccproxy.shaping.caching.insert |
Sets a value at a glom dot-path via assign(). Parameterized via InsertParams(path: str, value: Any). Default value: {"type": "ephemeral"}. reads/writes=["system.*.cache_control", "tools.*.cache_control"]. |
Anthropic limits explicit cache_control breakpoints to 4 per request. When prepend_shape:2 merges the shape's system preamble (which carries its own cache_control annotations) with the incoming system prompt, the total breakpoint count can exceed this limit, causing API rejections.
The caching hooks in ccproxy.shaping.caching solve this by normalizing breakpoints after content injection: strip all existing breakpoints, then insert exactly one at the optimal position for prefix caching.
Deletes values at one or more glom dot-paths using glom.delete() with ignore_missing=True. Non-existent paths are silently skipped.
- hook: ccproxy.shaping.caching.strip
params:
paths: ["system.*.cache_control"]StripParams fields:
| Field | Type | Description |
|---|---|---|
paths |
list[str] |
Glom dot-paths to delete. Supports wildcards. |
Sets a value at a single glom dot-path using glom.assign(). If the target path doesn't exist (e.g., empty list), the operation is silently skipped.
- hook: ccproxy.shaping.caching.insert
params:
path: "system.-1.cache_control"
value: {type: ephemeral}InsertParams fields:
| Field | Type | Default | Description |
|---|---|---|---|
path |
str |
— | Glom dot-path target. |
value |
Any |
{"type": "ephemeral"} |
Value to set at the path. |
The default config strips all cache_control from system blocks, then inserts one on the last block (optimal for prefix caching — the longest shared prefix gets cached):
shape_hooks:
- ccproxy.shaping.regenerate
- hook: ccproxy.shaping.caching.strip
params:
paths: ["system.*.cache_control"]
- hook: ccproxy.shaping.caching.insert
params:
path: "system.-1.cache_control"
value: {type: ephemeral}Before (after prepend_shape:2 merges system blocks):
system[0]: shape preamble → cache_control: {type: ephemeral} ← from shape
system[1]: shape preamble → cache_control: {type: ephemeral} ← from shape
system[2]: app system block → (none)
system[3]: app system block → cache_control: {type: ephemeral} ← from client
system[4]: app system block → cache_control: {type: ephemeral} ← from client
Total: 4 breakpoints. Any additional client breakpoint exceeds the limit.
After (strip + insert):
system[0]: shape preamble → (stripped)
system[1]: shape preamble → (stripped)
system[2]: app system block → (stripped)
system[3]: app system block → (stripped)
system[4]: app system block → cache_control: {type: ephemeral} ← inserted
Total: 1 breakpoint. The last block is the optimal position because prefix caching benefits from caching the longest shared prefix.
All hooks that perform raw body mutations use glom as the standard primitive — both for runtime access (glom(), assign(), delete() over ctx._body) and for reads/writes declarations that drive DAG dependency ordering. The DAG extracts the root field from each dot-path (e.g. "system.*.cache_control" → "system") for dependency resolution. Paths are dot-separated, with special syntax for list access:
| Pattern | Meaning | Example |
|---|---|---|
field.*.key |
Wildcard — iterates all items in the list | system.*.cache_control strips cache_control from every system block |
field.0.key |
Specific index | system.0.cache_control targets the first system block |
field.-1.key |
Negative index (last item) | system.-1.cache_control targets the last system block |
a.b.c |
Nested dict traversal | metadata.user_id reaches into nested dicts |
Numeric path segments auto-coerce to list indices. Non-numeric segments are dict key lookups.
apply_shape(shape, ctx, preserve_headers) (shaping/models.py) stamps the shape onto the outbound flow:
- Snapshot
preserve_headersvalues from the target flow (auth headers frominject_auth, host from redirect handler) - Clear ALL headers on the target flow
- Copy ALL shape headers (compliance headers, user-agent, beta flags, x-stainless-*, etc.)
- Restore the preserved headers (overwriting any shape values for those keys)
- Merge query parameters from the shape (e.g.
?beta=true) - Set
flow.request.content = shape.content - Resync
ctx._bodyfrom the shape content
Auth headers from inject_auth and the host from the transform router survive shaping. Everything else comes from the shape's compliance envelope. The preserve_headers list is configurable per-provider.
The shape hook reads its behavior entirely from the per-provider shaping profile in config.shaping.providers. The hook is a bare module path — no {hook, params} wrapper needed:
hooks:
outbound:
- ccproxy.hooks.inject_mcp_notifications
- ccproxy.hooks.verbose_mode
- ccproxy.hooks.shape
shaping:
enabled: true
shapes_dir: ~/.config/ccproxy/shapes
providers:
anthropic:
content_fields:
- model
- messages
- tools
- tool_choice
- system
- thinking
- context_management
- stream
- max_tokens
- temperature
- top_p
- top_k
- stop_sequences
merge_strategies:
system: "prepend_shape:2"
shape_hooks:
- ccproxy.shaping.regenerate
- hook: ccproxy.shaping.caching.strip
params:
paths: ["system.*.cache_control"]
- hook: ccproxy.shaping.caching.insert
params:
path: "system.-1.cache_control"
value: {type: ephemeral}
preserve_headers:
- authorization
- x-api-key
- x-goog-api-key
- host
strip_headers:
- authorization
- x-api-key
- x-goog-api-key
- content-length
- host
- transfer-encoding
- connection
capture:
path_pattern: "^/v1/messages"Field reference (ProviderShapingConfig):
| Field | Type | Default | Purpose |
|---|---|---|---|
content_fields |
list[str] |
[] |
Body keys injected from incoming request |
merge_strategies |
dict[str, str] |
{} |
Per-field override: replace, prepend_shape[:N], append_shape[:N], drop |
shape_hooks |
list[str | dict] |
[] |
Dotted module paths or {hook, params} dicts containing @hook-decorated functions, DAG-ordered |
preserve_headers |
list[str] |
auth + host | Target headers apply_shape must NOT overwrite |
strip_headers |
list[str] |
auth + transport | Shape headers to remove before stamping |
capture.path_pattern |
str |
"" |
Regex for flow validation during ccproxy shapes save |
Shape hooks use the standard @hook decorator with reads/writes for DAG ordering.
Simple hook (no parameters — registered as a bare module path):
# myproject/shaping/custom.py
from typing import Any
from glom import assign, glom
from ccproxy.pipeline.context import Context
from ccproxy.pipeline.hook import hook
@hook(reads=["custom_tracking_id"], writes=["custom_tracking_id"])
def inject_custom_metadata(ctx: Context, params: dict[str, Any]) -> Context:
"""Add a custom tracking field from the incoming request into the shape."""
incoming_ctx = params.get("incoming_ctx")
if incoming_ctx is not None:
value = glom(incoming_ctx._body, "custom_tracking_id", default=None)
if value is not None:
assign(ctx._body, "custom_tracking_id", value)
return ctxshape_hooks:
- myproject.shaping.customParameterized hook (accepts config-driven parameters via a Pydantic model):
# myproject/shaping/tag.py
from typing import Any
from glom import assign
from pydantic import BaseModel
from ccproxy.pipeline.context import Context
from ccproxy.pipeline.hook import hook
class TagParams(BaseModel):
key: str
value: str
@hook(reads=["metadata"], writes=["metadata"], model=TagParams)
def add_tag(ctx: Context, params: dict[str, Any]) -> Context:
"""Set a metadata tag from config params."""
path = f"metadata.{params['key']}"
assign(ctx._body, path, params["value"])
return ctxshape_hooks:
- hook: myproject.shaping.tag
params:
key: "environment"
value: "production"The model= kwarg on @hook declares a Pydantic model for parameter validation. When load_hooks() processes a {hook, params} entry, it validates params against the model and rejects invalid configurations at load time.
To add a new provider, add an entry under shaping.providers with the appropriate content_fields for that provider's API schema. No Python code changes required.
# Fresh install: bundled defaults are used automatically
just up
# Check the packaged request-only artifacts
ccproxy shapes audit
# Verification
# Run a request through the reverse proxy with the sentinel key, then:
ccproxy flows compare
# The diff shows the forwarded request carrying shape compliance headers
# alongside your actual message content
# Advanced development override; see "Manual Shaping When a Packaged Default Is Stale" above:
ccproxy run --inspect -- claude -p "shape refresh"
ccproxy shapes save anthropic
# Remove user customizations and return to the bundled default:
rm -rf ~/.config/ccproxy/shapes/anthropic ~/.config/ccproxy/shapes/anthropic.mflow| Symptom | Cause | Fix |
|---|---|---|
| "No shape available for provider X" in logs | No bundled default and no advanced local override for that provider | Install a ccproxy release that packages that provider shape; for custom-provider development, write an explicit .mflow override with ccproxy shapes save X --mflow |
| "No shaping profile for provider X" in logs | Missing provider config | Add shaping.providers.X to ccproxy.yaml |
| Shape hook not firing (no "Applied shape" log) | Guard condition not met: flow lacks transform, or entered via WireGuard passthrough | Verify transform/redirect routing exists; check that the flow entered through the reverse proxy or had auth injected |
| System prompt missing shape's preamble | merge_strategies misconfigured |
Ensure system: prepend_shape is set in the provider's merge_strategies config |
| 400 "too many cache_control breakpoints" | Shape system blocks carry cache_control that survives prepend_shape merge |
Add the strip and insert caching hooks to shape_hooks (see Cache Breakpoint Hooks) |
| 400/403 from provider after shaping | Stale packaged or local shape | Update ccproxy to a release with refreshed packaged defaults. If no fixed release exists yet, follow the manual shaping guide above. |
| Auth headers leaking from shape | strip_headers misconfigured |
Ensure authorization and x-api-key are in the provider's strip_headers list |