Skip to content

feat(ccproxy): v2.0.0 — inspector architecture, lightllm, DAG pipeline, compliance - #16

Open
starbaser wants to merge 466 commits into
mainfrom
dev
Open

starbaser wants to merge 466 commits into
mainfrom
dev

Conversation

@starbaser

@starbaser starbaser commented Apr 16, 2026

Copy link
Copy Markdown
Owner

AI Summary

Complete rewrite of ccproxy from a LiteLLM proxy subprocess model to an in-process mitmproxy-based transparent LLM API interceptor. This is the v2.0.0 release (tagged v2.0.0-rc1).

  • Inspector architecture: mitmweb runs in-process via WebMaster API with dual listeners — reverse proxy + WireGuard namespace jail. No subprocess, no gateway server.
  • lightllm: Surgical nerve connector into LiteLLM's BaseConfig transformation pipeline, bypassing cost tracking and callback machinery entirely.
  • DAG-based hook pipeline: @hook(reads=..., writes=...) decorator-declared data dependencies, topologically sorted via Kahn's algorithm. Per-request overrides via x-ccproxy-hooks header.
  • SSE streaming: SseTransformer stateful stream callable — parses, transforms per-chunk via LiteLLM's provider iterators, re-serializes as OpenAI-format SSE.
  • Compliance profile learning: Provider-agnostic system that observes legitimate request shapes from WireGuard traffic and stamps compliance profiles onto proxied requests.
  • Gemini/Vertex AI support: Full routing, OAuth handling, context caching via cachedContents API, path rewriting for cloudcode-pa.googleapis.com.
  • Flows CLI: ccproxy flows list/dump/diff/compare/clear with multi-page HAR 1.2 output, jq filtering, and sliding-window diff across flow sets.
  • MCP notification endpoint: POST /mcp/notify for terminal event ingestion, buffered and injected as synthetic tool_use/tool_result pairs.
  • XDG config directory: Default config moved to ~/.config/ccproxy/ (breaking change).
  • init replaces install: CLI rename (breaking change).
  • Rich pipeline visualization: render_pipeline() builds a full DAG display with parallel groups via rich.columns.Columns.

Breaking Changes

  • Config directory: ~/.ccproxy/~/.config/ccproxy/
  • CLI: ccproxy installccproxy init
  • --debug flag replaced by --log-level / -v
  • forward_port / reverse_port replaced by unified port config
  • mitm config section renamed to inspect
  • Prisma/database infrastructure removed entirely
  • LiteLLM proxy subprocess removed
  • to_mermaid / to_ascii removed from HookDAG

Test plan

  • just test passes with ≥90% coverage
  • just lint / just typecheck clean
  • Smoke test: ccproxy run --inspect -- claude --model haiku -p "what's 2+2"
  • Verify ccproxy init creates config at ~/.config/ccproxy/
  • Verify flows CLI: ccproxy flows list, ccproxy flows dump
  • Verify Gemini routing through inspector

Repository owner deleted a comment from gitguardian Bot Apr 16, 2026
Comment thread .github/workflows/notify-marketplace.yml Fixed
Comment thread src/ccproxy/cli.py Dismissed
Comment thread src/ccproxy/cli.py Fixed
Comment thread src/ccproxy/config.py Fixed
Comment thread src/ccproxy/config.py Fixed
Comment thread src/ccproxy/config.py Fixed
Comment thread src/ccproxy/hooks/gemini_oauth_refresh.py Fixed
Comment thread src/ccproxy/inspector/addon.py Fixed
Comment thread src/ccproxy/inspector/addon.py Fixed
Comment thread src/ccproxy/inspector/process.py Fixed
Comment thread src/ccproxy/preflight.py Fixed
Repository owner deleted a comment from gitguardian Bot Apr 16, 2026
Repository owner deleted a comment from gitguardian Bot Apr 16, 2026
…re output

Remove Panel, Rule, Syntax from diff and compare commands. Diffs now
delegate to `git --no-pager diff --no-index --color=auto` which respects
the user's git config (diff.algorithm, diff.colorMoved, diff.external,
color.diff) and does native TTY-aware color auto-detection. Errors and
status messages route through Console(stderr=True) so data output on
stdout stays clean for piping. `list --json` uses raw print() instead
of console.print_json().
_resolve_project() now retries loadCodeAssist on 401 by refreshing the
Gemini OAuth token and updating the flow's auth header. Strips phantom
metadata dict injected by extract_session_id before wrapping in the
v1internal envelope — Google rejects unknown fields at 'request'.
Adds compliance.profile_path config option so all project instances
share a single compliance profiles file from ~/.config/ccproxy/ instead
of each writing to their own {config_dir}/compliance_profiles.json.
…ding

Replace the passive compliance observation pipeline with an explicit
`ccproxy flows seed --provider X` CLI subcommand. Users now capture
reference traffic through the inspector, filter flows with --jq, and
seed compliance profiles from curated flows. This gives full control
over which flows contribute to profiles.

New: ComplianceSeeder addon (ccproxy.seed mitmproxy command), FlowsSeed
CLI class, ProfileStore.set_profile(). Removed: observe_flow pipeline,
submit_observation, accumulator persistence, min_observations config.

Also fixes WireGuard namespace ignore_hosts race — first TLS passthrough
to ignore_hosts domains failed intermittently, breaking Gemini CLI OAuth.
Added _warmup_ignore_hosts() to prime connections before child command.
apply_compliance previously only ran on reverse proxy flows, meaning
WireGuard flows with OAuth token injection (e.g., google-genai SDK
through gemini_cli_compat → reroute_gemini) never received compliance
profiles. Now also triggers on flows where forward_oauth set
flow.metadata["ccproxy.oauth_injected"].

Also fixes test suite: isolate _warmup_ignore_hosts from namespace
nsenter tests, add dedicated warmup tests, fix CWD-dependent log path
assertion, remove 4 vestigial test files importing deleted modules.
…overwrite headers

Replace the merge-based compliance model with a stamp-based one:
ComplianceMerger → ComplianceStamper, apply_compliance → stamp_compliance.
Headers from the compliance profile now overwrite existing values instead
of skipping them, so SDK user-agents are replaced with the canonical CLI
fingerprint from the seeded profile. List-valued headers (anthropic-beta)
still union tokens. Also adds sentinel-key-only rule to CLAUDE.md and
removes stale gemini_cli_compat 429 guidance.
…eline

Introduce Envelope as a first-class dataclass shared across the entire
compliance lifecycle — extraction, accumulation, persistence, and
stamping — eliminating the ProfileFeature* serialize/deserialize dance.

- Add Envelope dataclass (headers dict, body_fields dict, system blocks,
  body_wrapper) as the shared currency between seeding and stamping
- Simplify ComplianceProfile to wrap a single Envelope instead of
  separate ProfileFeatureHeader/BodyField/System lists
- Remove ProfileFeatureHeader, ProfileFeatureBodyField,
  ProfileFeatureSystem, and ObservationBundle classes
- Rename extract_observation → extract_envelope, returns Envelope
  directly with string system prompts normalized to block lists
- Rewrite ComplianceStamper as a two-phase prepare/wrap pipeline:
  prepare_envelope() builds a MaterializedEnvelope (pure, no ctx),
  wrap() fills it with the incoming request (order-sensitive: metadata
  inside wrapper, body_fields outside)
- Bump store format_version 1 → 2 (degraded-mode handles mismatch)
Removes ComplianceStamper and ProfileStore in favor of SeedStore that
persists raw mitmproxy flows. The new husk hook picks a seed, strips
content via prepare functions, fills it with incoming request data via
fill functions, then applies the result. This separates seed storage
from transformation logic.
Replaces stamp_compliance with a seed-based approach: prepare functions
strip original content from recorded seeds, fill functions inhabit them
with incoming request data. Users compose custom pipelines via the husk
hook's prepare and fill params.

BREAKING CHANGE: removed stamp_compliance hook, use husk hook with
  prepare/fill params instead
Removes the profile-based stamping pipeline in favor of direct husk
application, simplifying the compliance flow integration.
Envelope extraction logic is no longer needed in the compliance flow.
Deleted unused compliance profile classification logic including header
and body field filtering functions.
Migrates from profile-based stamping to a two-phase seed/husk
architecture with explicit prepare and fill steps.

BREAKING CHANGE: removed `profile_path`, `seed_anthropic`,
  `additional_header_exclusions`,
  `additional_body_content_fields`, and `stamper_class`
  from ComplianceConfig; replaced with `seeds_dir`
The compliance module now uses user-curated seed flows and composable
prepare/fill pipelines instead of automatic profile learning, giving
users explicit control over request shaping.

BREAKING CHANGE: removed `ComplianceProfile`, `ObservationAccumulator`,
  `ComplianceMerger`; replaced `apply_compliance` hook
  with `husk` hook requiring `prepare`/`fill` params in
  config
Restructures compliance processing into explicit prepare and fill stages
for better separation of concerns. Moves profile storage from single
JSON file to seeds directory.

BREAKING CHANGE: config key `compliance.profile_path` renamed to
  `compliance.seeds_dir`
Priority now defines canonical execution sequence. Read/write tracking
becomes a parallelism hint rather than the sole dependency source,
preventing spurious cycles.
apply_husk was clobbering host/port/scheme/path set by the redirect
handler and wiping auth headers injected by forward_oauth, causing TLS
handshake failures (seed had raw IPs) and 401s (auth lost). Now only
stamps compliance-relevant headers and body from the seed, preserving
transport routing and auth. Also strips model-capability fields
(thinking, output_config, context_management) from seeds and fixes
FlowRecord serialization in the compliance seeder.
…meterized strip_system_blocks

Single prepare fn with Python slice syntax via keep arg (e.g. ":1", "1:", "-2:").
Husk hook resolves "mod.fn(arg)" entries via functools.partial. Pipeline render
shows prepare/fill as numbered side-by-side columns with common prefix stripped.
Replace the seed/husk metaphor with a simpler "shape" metaphor:
capture a shape, prepare it, fill it, apply it.

- compliance/ package → shaping/
- Husk type → Shape, SeedStore → ShapeStore
- husk hook → shape hook, ccproxy.seed cmd → ccproxy.shape
- ccproxy flows seed → ccproxy flows shape
- ComplianceConfig → ShapingConfig, seeds_dir → shapes_dir
- All imports, tests, docs, nix config, YAML templates updated
The old _do_seed expected profile-era keys (key, flows_used, headers,
body_fields, system). The ccproxy.shape command returns (status,
provider, flows_saved, missing). Pre-existing bug, now fixed.
mitmproxy 12 metadata iteration yields FlowMeta objects, not plain
strings — .startswith() fails without explicit str() cast.
mitmproxy 12 metadata contains FlowMeta enum keys that FlowWriter
cannot serialize. Strip all non-string keys alongside ccproxy runtime
keys when capturing shapes.
_resolve_entry("mod.fn(arg)") was using functools.partial(fn, arg)
which bound arg as the first positional — stomping over the shape
parameter. Now introspects the function signature to find the first
defaulted parameter and binds by keyword.

Also strips non-string metadata keys (FlowMeta enums) in
_strip_runtime_metadata for mitmproxy 12 compat.
starbaser added 30 commits June 27, 2026 19:30
… deltas

Current ChatGPT often streams the answer inline via the string content path
/message/content/text (content_type text|code, a 'text' field, not a 'parts'
array) followed by bare {"v":"..."} continuation deltas that continue the
last (path, op) with an implied append (recon §2 — the SSE-v1 majority shape).
The intake only handled /message/content/parts/0 and dropped bare string
deltas, rendering those turns empty.

Add a last_path cursor + _TEXT_CONTENT_PATHS (parts/0 + content/text); resolve
bare deltas against the cursor as an append; route both content paths through
the existing suffix-diff logic. Additive — parts/0 streaming is unchanged.
Verified against the recon doc's 'Paris' split-token example (full + split-chunk
+ collect mode) plus a bare-before-content silence check.
Rework the conduit WS reader to handle whatever comes down the line instead of
switching on envelope type. _walk_sse_items recursively pulls every encoded_item
SSE payload at any depth (message payload.payload, conversation-turn-stream
stream-item payload.encoded_item, reply catchups), tagging each with its nearest
topic; _decode_encoded_item tolerates plaintext or base64. Turn-topic items are
forwarded to the intake; the turn ends on the structural [DONE]/done signal.

Never silently drop: every inbound WS message is recorded in a bounded
WSFrameCapture ring (new ws_capture.py singleton), forwarded or not, so non-turn
/ unhandled events (app_notifications, other conversations) stay inspectable.

Removed the reply/message/else-continue branching and the two-level encoded_item
assumption. _WS_READ_TIMEOUT is reframed as a temporary idle backstop (not a
functional terminator; to be removed once live turns prove structural EOS) — the
socket is never torn down on a timer. Unit-tested exhaustively against all known
frame shapes; LIVE VERIFICATION still pending (intermittent conduit route).
…nostic

Live forced-handoff verification (skip conduit prewarm to provoke the rare
server handoff) confirmed the WS transport works against the real server:
celsius/ws/user -> 200, dialed wss, subscribed the JWT turn_topic_id,
_walk_sse_items structurally extracted 10 items, terminated on structural
[DONE] — the old 'connects but hangs 120s with no content' failure is gone.

But that handoff yielded content=None: forwarded=10 items, zero text. That
signature means the items are NOT plaintext 'data:' SSE — consistent with the
SPA _Nn decode, where encoded_item is a {event,data} object (raw or base64)
whose 'data' field carries the SSE-v1 patch. _decode_encoded_item now unwraps
that shape into an event:/data: line (tolerant, graceful fallback so the
plaintext/base64-SSE shapes still work). Verified end-to-end in unit tests:
a conversation-turn-stream/stream-item frame with a base64 {event,data}
encoded_item now flows extract -> decode -> intake -> text.

Also log every raw WS frame at DEBUG (ws_handoff: RAWFRAME) so the next
handoff is fully diagnosable.

LIVE STATUS: transport verified; the {event,data} content fix is evidence-
grounded (SPA _Nn + the forwarded=10/None signature) and unit-tested, but NOT
yet confirmed on a live handoff — the conduit route fired once in ~55 forced
turns and could not be recaptured. Confirm on the next organic handoff.
Reading the real client code (chatgpt.com sdk.js _Nn) settles the encoding
question: _Nn parses encoded_item AS an SSE stream (data:/event: line parser);
aurora checks 'data: [DONE]' literally inside it. So encoded_item is already
plaintext SSE — there is NO base64/{event,data} wrapper. The previous decoder
branch conflated _Nn's OUTPUT ({event,data}) with the encoding of its INPUT.
_decode_encoded_item is now an honest passthrough that logs any non-SSE payload
rather than transforming it on a guess.

So content=None on the live handoff was never an encoding problem — the intake
was silently dropping the answer. Add telemetry so it never does so unobserved:
count + DEBUG-log content patches that arrive off the tracked final-answer
channel, and assistant text messages NOT adopted as final (the WS catch-up
replays a message already status=finished_successfully, which
_is_final_answer_message rejects — the prime suspect). intake.close() emits a
WARNING when a stream produced NO text despite carrying answer content.
_parse_frame logs unparseable/non-object SSE frames.

The next conduit handoff now diagnoses itself from the logs instead of
returning a silent empty turn.
Extend the never-silently-drop discipline (already on the OpenAI
Conversations intake) across the whole lightllm response graph: every
intake and render FSM, the SSE pipeline, the buffered transform, and the
dispatch glue.

Intakes (anthropic / openai / google / perplexity / openai_responses):
add frames_seen / frames_unparseable / emitted_events counters, a DEBUG
log at each unparseable-frame drop, and a close() WARNING when the stream
carried frames but emitted zero IR events (guarded by refusal where
applicable). Expose .state for inspection.

Renders (openai / anthropic / openai_responses): track events_received /
bytes_emitted; WARNING on close() when events arrived but no content bytes
were rendered. Expose .state.

sse_pipeline: WARNING when EOS produced no content bytes (streaming) or
assembled no parts (collect mode).

buffered: WARNING when assembling a contentless object, an empty synthetic
SSE, or a choiceless ChatCompletion.

dispatch (__init__): DEBUG the resolved provider_type/inbound_format → FSM
choice; WARNING before each unsupported-type raise.

Additive only — no parsing/rendering behavior change. caplog-based tests
assert each WARNING fires on a contentless/dropped stream and stays silent
on a clean one.
…answers)

The WS catch-up replays the assistant message already
status=finished_successfully, which _is_final_answer_message rejects — so on a
conduit handoff no final_channel was set and every content patch was dropped
(the live content=None). Keying adoption on status is wrong for a replay.

Fix: adopt the channel where visible content actually lands. handle_add records
every assistant content_type=text (non-hidden) channel as a candidate regardless
of status; handle_patch lazily adopts a candidate the first time content arrives
on it when no in-progress add anchored one. The eager in-progress path is
unchanged (inline turns behave exactly as before); historical/finished messages
are only adopted if they actually receive content patches, which inline they
never do.

Grounded in the intake's own logic, not a wire guess. The off-channel /
no-text close() telemetry remains the backstop if a future handoff drops content
for another reason. Live-unconfirmed (the conduit route fired once in ~185
forced turns); the telemetry will confirm on the next organic handoff.
…ns + images)

End-user usage guide for the openai_conversations provider: provider config,
credential file (access_token/device_id), gateau cookie export, the sentinel
key, a runnable Pydantic AI example (PEP 723, verified live: text + multi-turn
session), how sessions work (message-history threading + the optional
server-side thread-inject hook), the images API (generations/edits), models,
and troubleshooting. The embedded example was run standalone via 'uv run'
against a real chatgpt-paid account (turn1='noted', turn2='Kyle').
The image side-trips (file upload, conversation poll, download-metadata) were
the one place sending a thin 2-header request (Bearer + oai-device-id) instead
of the full browser shape every other backend-api call uses — so the image poll
got WAF-403'd 40x (not a stale key; the same bearer 200'd the POST seconds
earlier). Root cause: a local _auth_headers helper bypassing the canonical
builder; the image path (CHATGPT-006) was built without a live credential and
never exercised.

Add get_api_headers (the one canonical authenticated chatgpt.com /backend-api
identity = full browser shape + Bearer) and route everything through it: the
addon's sentinel/conduit/final builders AND image upload/poll/download-metadata.
The presigned blob legs (Azure PUT, signed download GET) stay bare — a Bearer
there is itself a 403. Live-verified: the image poll now returns HTTP 200 (was
403); the image generates. Lock-in tests assert the canonical shape.

Remaining async_status=4 (ChatGPT delivers the finished image via the async
notification, not the conversation) is a separate follow-up.
Mirrors talkstream's render_graphs.py: imports the built pydantic-graph FSMs
and emits their native render() mermaid — single source of truth, no stubs.
Renders all 6 intake + 3 render graphs plus their 13 subgraphs (22 diagrams),
with subgraphs shown both as parent step-nodes and expanded individually.
docs/lightllm_graphs.md is the generated artifact; regenerate with
'uv run python -m ccproxy.lightllm.graph.render_graphs --markdown'.
… not no-asset)

ChatGPT's image turn is async: the conversation reports async_status:3 while
generating, then async_status:4 once done — and the image_asset_pointer IS
present at that point (live-verified by fetching a completed image conversation:
async_status=4 carried a sediment:// pointer, 1536x1024). The poll raised on
async_status==4 BEFORE extracting the pointer, so every picture_v2 image was
discarded as 'finished without assets'.

Fix: extract the pointer each poll and return it when present; async_status==4
is the DONE signal, and a status-4 conversation with no pointer is the genuine
failure. Combined with the header unification, the full picture_v2 path now
works end-to-end (generate -> async poll -> download-metadata -> presigned
download): live-verified by generating a 'graph atlas' image and retrieving the
1.8MB PNG. Test covers both status-4-with-pointer (returns) and
status-4-without-pointer (raises).
Establishes convention for reading sibling first-party project docs
before working with their APIs, ensuring agents consult curated
documentation rather than inferring from source alone.
Consolidates duplicated FSM state slots (usage, raw_extras,
finish_reason, provider_response_id) and SSE framing logic from all
provider-specific intake/render modules into shared base classes. Adds
_usage.py helpers to capture and project token accounting across wire
formats, fixing silent usage drops in cross-format transforms.
Nix's python setup hook aggregates all python packages into PYTHONPATH,
creating a python 3.14 closure that shadows the project's 3.13 venv and
breaks ABI-sensitive imports like mypy. Moving mypy to uv dev
dependencies and unsetting PYTHONPATH ensures the uv venv owns the
Python environment.
Library-only, inbound-only cross-IR transform bridging Alloy's
provider-specialized prompt AST JSON into the pydantic-ai ModelMessage IR
lightllm operates on. AST → IR directly (never lowered through a wire
format). No InboundFormat, no Context, no dispatch wiring — the proxy
pipeline is untouched.

parsed_request_from_alloy(prompt_ast, client_view) builds a ParsedRequest
a consumer hands to dispatch_dump_sync. Node grouping mirrors the wire
adapters; media maps to URL/BinaryContent (no media_type fallback); local
file sources and media in system/assistant fail loudly; message metadata
follows the VM-003 cache_control forward contract.
…t-aware tool caching

Adds the CachePolicy placement engine (Anthropic 4-breakpoint budget,
authored-wins, idempotent) with the cache_breakpoints outbound hook
(x-ccproxy-cache-policy header DSL / hook params), and makes the Anthropic
adapter deferment-aware: ToolDefinition.defer_loading round-trips both
directions, the dump side stamps cache_control on the last non-deferred
tool only (pydantic-ai contract; Anthropic rejects markers on deferred
tools), and the load-side lift fires only for the canonical single-marker
boundary shape — every other stamping pattern rides raw_extras["tools"]
verbatim.
…, typed-tool wire fidelity

Intake: branch tool_search_tool_result blocks to pydantic-ai's
_map_tool_search_tool_result_block (success → discovered_tools, error →
provider_details) — previously they fell to the unknown-block catch-all and
were dropped, orphaning the server_tool_use call side (Anthropic 400s
unpaired tool_search_tool_* on replay). Also fixes streamed server_tool_use
args: emit with args=None when the block's input is empty so input_json_delta
strings can attach, then finalize tool-search args to the canonical
ToolSearchArgs dict at content_block_stop (mirrors upstream's streaming path;
the buffered synthesizer's full-input start events are unaffected).

Adapter: any tool entry whose wire type isn't "custom" now rides the
raw_extras["tools"] verbatim override — server/builtin tools (versioned type,
side fields like max_uses) were being flattened to bare name+schema on
round-trip. ToolDefinitions are still built for typed promotion; the
canonical-marker lift is skipped when the typed override fires. Adds the
tool_search_tool_bm25/regex_20251119 discriminators to ANTHROPIC_TYPED_TOOLS.

Cache engine: census counts cache_control markers riding the tools override,
and the tools placement is skipped whenever the override is present (a knob
placement would burn budget for a wire no-op — the verbatim array overwrites
formatted tools at stitch time).

Docs: raw_extras tools triggers, promotion-vs-preservation, census/skip
rules, and the tool-search invariants hooks must honor (pair orphaning,
tool_reference stranding, dump/render-side limitations).
Replaces hardcoded 120s literals in session_ws and ws_handoff with a
shared config field. Idle give-ups now emit a typed ccproxy_idle_timeout
terminal frame (finish_reason="error") instead of ending silently, so
truncated turns are distinguishable from genuine [DONE] completions.
SSEPipeline._drain_and_terminate() handed the render close() implementations
only usage and raw_extras, so every streaming terminator announced a fixed
outcome: the OpenAI Chat finish chunk said "stop", the Anthropic message_delta
said "end_turn", the Responses postlude said response.completed. A turn cut
short at the token ceiling, stopped by a content filter, or killed by an
upstream error was byte-for-byte indistinguishable from a clean completion for
every provider's cross-format streaming transform.

The intake funnel already carried the answer. Thread it: close() gains a
finish_reason keyword on the base contract, the pipeline supplies the intake's
capture, and one new module owns the per-listener vocabulary — the finish-reason
twin of _usage.py, with from_* capture and to_* projection. Both transport paths
call it, so a stream:true client and a stream:false client are told the same
thing about how the turn ended.

Capture gaps closed at the same seam, without which the threading would stay
half-dead for two providers:

- The Anthropic intake never read message_delta.delta.stop_reason, though its
  modeled-keys set already claimed stop_reason was modeled. Raw wire values that
  the IR cannot express (pause_turn, compaction) now ride raw_extras instead of
  vanishing.
- The Google intake never read candidate.finishReason. Capture runs before the
  contentless-candidate short-circuit, which is the exact frame a MAX_TOKENS cut
  arrives on.

Phantom surfaces removed rather than left lying: the buffered Anthropic
assembler's stop_reason parameter that no caller passed is now plumbed;
_OPENAI_FINISH_BY_PART was read by nothing; the Chat render state's five-member
finish_reason Literal was only ever set to "tool_calls" and becomes the boolean
it always was; the Responses render state's documented finish_status was never
assigned at all.

"error" reaches the OpenAI Chat and Anthropic wires as "error" — neither closed
enum has a member for it, and a fabricated completion is the lie this change
exists to remove. The buffered Chat body already emitted it; Stainless SDKs
parse response enums permissively, so no client hard-fails on it.

Regressions drive real SSEPipeline instances end to end; eleven of them fail on
the pre-fix code, each crossing the exact former threshold.
Replace the removed ccproxy run --inspect flag with the live --capture interface throughout the inspector skill. Include the existing routine flake input refresh already present in the shared worktree.
DeepSeek's API serves deepseek-v4-pro and deepseek-v4-flash; the
declared deepseek-v4 binding resolves to no upstream model, so any
request routed to it fails at the provider.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants